feat(hq-web): clients, modules, composer and document pages (HQ-1 task 16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
10529beb42
commit
8c145d5a3b
@ -0,0 +1,261 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { formatINR, fromRupees } from '@sims/domain'
|
||||
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, StatCard, Stats, Toolbar } from '@sims/ui'
|
||||
import {
|
||||
assignClientModule, getClient, getClientModules, getLedger, getModules,
|
||||
patchClient, patchClientModule, recordPayment,
|
||||
CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES,
|
||||
type ClientModule, type ClientModuleStatus, type ClientStatus, type Kind,
|
||||
type Module, type PaymentMode,
|
||||
} from '../api'
|
||||
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
|
||||
|
||||
const inr = (p: number) => formatINR(p, { symbol: false })
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
/** Client 360° — header, modules, documents, payments & dues (14-SPEC §Client registry). */
|
||||
export function ClientDetail() {
|
||||
const id = useParams()['id'] ?? ''
|
||||
const nav = useNavigate()
|
||||
const client = useData(() => getClient(id), [id])
|
||||
const modules = useData(getModules, [])
|
||||
const cms = useData(() => getClientModules(id), [id])
|
||||
const ledger = useData(() => getLedger(id), [id])
|
||||
const [actionErr, setActionErr] = useState<string | undefined>()
|
||||
|
||||
const c = client.data
|
||||
if (client.error !== undefined) return <Notice tone="err">{client.error}</Notice>
|
||||
if (c === undefined) return <EmptyState>Loading…</EmptyState>
|
||||
|
||||
const moduleName = (moduleId: string) =>
|
||||
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title={c.name} badge={c.code}
|
||||
desc={`State ${c.stateCode}${c.gstin !== undefined && c.gstin !== '' ? ` · GSTIN ${c.gstin}` : ''}${c.address !== '' ? ` · ${c.address}` : ''}`}
|
||||
actions={
|
||||
<select
|
||||
className="wf" value={c.status}
|
||||
onChange={(e) => {
|
||||
setActionErr(undefined)
|
||||
patchClient(id, { status: e.target.value as ClientStatus })
|
||||
.then(client.reload).catch((err: Error) => setActionErr(err.message))
|
||||
}}
|
||||
>
|
||||
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
<Toolbar>
|
||||
<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>
|
||||
{c.contacts.map((ct, i) => (
|
||||
<Badge key={i}>{[ct.name, ct.email, ct.phone].filter((x) => x !== undefined && x !== '').join(' · ')}</Badge>
|
||||
))}
|
||||
</Toolbar>
|
||||
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
|
||||
|
||||
<h3>Modules</h3>
|
||||
{modules.data !== undefined && (
|
||||
<AssignModuleForm
|
||||
modules={modules.data}
|
||||
onAssign={(moduleId, kind) => {
|
||||
setActionErr(undefined)
|
||||
assignClientModule(id, { moduleId, kind })
|
||||
.then(() => { cms.reload(); ledger.reload() })
|
||||
.catch((err: Error) => setActionErr(err.message))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cms.error !== undefined && <Notice tone="err">{cms.error}</Notice>}
|
||||
{cms.data === undefined || cms.data.length === 0
|
||||
? <EmptyState>No modules assigned yet.</EmptyState>
|
||||
: (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
|
||||
{ key: 'trained', label: 'Trained' },
|
||||
{ key: 'billed', label: 'Billed', numeric: true },
|
||||
{ key: 'settled', label: 'Settled', numeric: true },
|
||||
]}
|
||||
rows={cms.data.map((cm) => {
|
||||
const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
|
||||
return {
|
||||
module: moduleName(cm.moduleId),
|
||||
kind: KIND_LABEL[cm.kind],
|
||||
status: (
|
||||
<ClientModuleStatusSelect
|
||||
cm={cm}
|
||||
onPatched={() => cms.reload()}
|
||||
onError={setActionErr}
|
||||
/>
|
||||
),
|
||||
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
|
||||
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
|
||||
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
|
||||
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
|
||||
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: 20 }}>Documents</h3>
|
||||
{ledger.error !== undefined && <Notice tone="err">{ledger.error}</Notice>}
|
||||
{ledger.data === undefined || ledger.data.documents.length === 0
|
||||
? <EmptyState>No documents yet — compose one from New Document.</EmptyState>
|
||||
: (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'no', label: 'No' }, { key: 'type', label: 'Type' },
|
||||
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]}
|
||||
onRowClick={(_row, i) => nav(`/documents/${ledger.data!.documents[i]!.id}`)}
|
||||
rows={ledger.data.documents.map((d) => ({
|
||||
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
|
||||
type: d.docType, date: d.docDate,
|
||||
payable: inr(d.payablePaise),
|
||||
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: 20 }}>Payments & dues</h3>
|
||||
{ledger.data !== undefined && (
|
||||
<Stats>
|
||||
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
|
||||
</Stats>
|
||||
)}
|
||||
<PaymentForm
|
||||
clientId={id}
|
||||
onDone={() => { ledger.reload(); cms.reload() }}
|
||||
/>
|
||||
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
|
||||
{ key: 'ref', label: 'Reference' },
|
||||
{ key: 'amount', label: 'Amount', numeric: true },
|
||||
{ key: 'tds', label: 'TDS', numeric: true },
|
||||
]}
|
||||
rows={ledger.data.payments.map((p) => ({
|
||||
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
|
||||
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId: string, kind: Kind) => void }) {
|
||||
const [moduleId, setModuleId] = useState('')
|
||||
const mod = props.modules.find((m) => m.id === moduleId)
|
||||
const [kind, setKind] = useState<Kind | ''>('')
|
||||
return (
|
||||
<Toolbar>
|
||||
<select
|
||||
className="wf" value={moduleId}
|
||||
onChange={(e) => { setModuleId(e.target.value); setKind('') }}
|
||||
>
|
||||
<option value="">Assign module…</option>
|
||||
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
{mod !== undefined && (
|
||||
<select className="wf" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
|
||||
<option value="">Kind…</option>
|
||||
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
tone="primary"
|
||||
onClick={() => { if (moduleId !== '' && kind !== '') { props.onAssign(moduleId, kind); setModuleId(''); setKind('') } }}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Toolbar>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientModuleStatusSelect(props: {
|
||||
cm: ClientModule; onPatched: () => void; onError: (msg: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="wf" value={props.cm.status}
|
||||
onChange={(e) => {
|
||||
patchClientModule(props.cm.id, { status: e.target.value as ClientModuleStatus })
|
||||
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
|
||||
}}
|
||||
>
|
||||
{CLIENT_MODULE_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function RowDate(props: {
|
||||
cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn'
|
||||
onPatched: () => void; onError: (msg: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="date" className="wf" style={{ width: 140 }}
|
||||
value={props.cm[props.field] ?? ''}
|
||||
onChange={(e) => {
|
||||
patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null })
|
||||
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a payment against the client — amounts entered in rupees, stored in
|
||||
* integer paise via fromRupees. Shared with DocumentView's "Record payment".
|
||||
*/
|
||||
export function PaymentForm(props: { clientId: string; onDone: () => void }) {
|
||||
const [f, setF] = useState({ amountRs: '', tdsRs: '', mode: 'bank' as PaymentMode, reference: '', receivedOn: today() })
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
|
||||
setF((p) => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const save = () => {
|
||||
const amount = Number(f.amountRs)
|
||||
if (!Number.isFinite(amount) || amount <= 0) { setError('Enter a payment amount in rupees'); return }
|
||||
const tds = f.tdsRs === '' ? 0 : Number(f.tdsRs)
|
||||
if (!Number.isFinite(tds) || tds < 0) { setError('TDS must be a non-negative rupee amount'); return }
|
||||
setError(undefined)
|
||||
setSaving(true)
|
||||
recordPayment({
|
||||
clientId: props.clientId, receivedOn: f.receivedOn, mode: f.mode, reference: f.reference,
|
||||
amountPaise: fromRupees(amount), tdsPaise: fromRupees(tds),
|
||||
})
|
||||
.then(() => { setF({ amountRs: '', tdsRs: '', mode: 'bank', reference: '', receivedOn: today() }); props.onDone() })
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, margin: '10px 0' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<Field label="Amount (₹)"><input className="wf num" style={{ width: 120 }} value={f.amountRs} onChange={set('amountRs')} /></Field>
|
||||
<Field label="TDS (₹)"><input className="wf num" style={{ width: 100 }} value={f.tdsRs} onChange={set('tdsRs')} /></Field>
|
||||
<Field label="Mode">
|
||||
<select className="wf" value={f.mode} onChange={set('mode')}>
|
||||
{PAYMENT_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Reference"><input className="wf" value={f.reference} onChange={set('reference')} /></Field>
|
||||
<Field label="Received on"><input type="date" className="wf" value={f.receivedOn} onChange={set('receivedOn')} /></Field>
|
||||
<Button tone="primary" onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button>
|
||||
</div>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
|
||||
import { createClient, getClients, type ClientStatus, type DocStatus } from '../api'
|
||||
|
||||
/** Tiny fetch-render hook shared by the HQ pages (same shape as backoffice live.tsx). */
|
||||
export function useData<T>(loader: () => Promise<T>, deps: unknown[] = []): {
|
||||
data?: T; error?: string; reload: () => void
|
||||
} {
|
||||
const [data, setData] = useState<T | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const reload = () => {
|
||||
setError(undefined)
|
||||
loader().then(setData).catch((e: Error) => setError(e.message))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, deps)
|
||||
return { data, error, reload }
|
||||
}
|
||||
|
||||
type Tone = 'ok' | 'warn' | 'err' | 'accent'
|
||||
|
||||
export const CLIENT_TONE: Record<ClientStatus, Tone> = {
|
||||
lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err',
|
||||
}
|
||||
|
||||
export const DOC_TONE: Record<DocStatus, Tone> = {
|
||||
draft: 'warn', sent: 'accent', accepted: 'ok', invoiced: 'accent',
|
||||
part_paid: 'warn', paid: 'ok', lost: 'err', cancelled: 'err',
|
||||
}
|
||||
|
||||
/** Client registry — search, list, inline create; row click opens the Client 360°. */
|
||||
export function Clients() {
|
||||
const nav = useNavigate()
|
||||
const [q, setQ] = useState('')
|
||||
const { data, error, reload } = useData(() => getClients(q), [q])
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
desc="Every shop we serve — leads through lost. Click a row for the client 360°."
|
||||
actions={<Button tone="primary" onClick={() => setCreating((v) => !v)}>New client</Button>}
|
||||
/>
|
||||
<Toolbar>
|
||||
<input
|
||||
className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
|
||||
value={q} onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Badge>{data?.length ?? '…'} clients</Badge>
|
||||
</Toolbar>
|
||||
{creating && (
|
||||
<NewClientForm onCreated={(id) => { setCreating(false); reload(); nav(`/clients/${id}`) }} />
|
||||
)}
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{data === undefined ? <EmptyState>Loading…</EmptyState>
|
||||
: data.length === 0 ? <EmptyState>No clients{q !== '' ? ` matching “${q}”` : ' yet — add the first one'}.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
|
||||
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
|
||||
{ key: 'contact', label: 'Contact' },
|
||||
]}
|
||||
onRowClick={(_row, i) => nav(`/clients/${data[i]!.id}`)}
|
||||
rows={data.map((c) => ({
|
||||
code: c.code, name: c.name,
|
||||
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
|
||||
state: c.stateCode,
|
||||
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline create — name + state are enough; GSTIN is checksum-validated server-side. */
|
||||
function NewClientForm(props: { onCreated: (id: string) => void }) {
|
||||
const [f, setF] = useState({ name: '', stateCode: '32', gstin: '', email: '', phone: '' })
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
|
||||
setF((p) => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const save = () => {
|
||||
if (f.name.trim() === '') { setError('Name is required'); return }
|
||||
const contact = f.email !== '' || f.phone !== ''
|
||||
? [{
|
||||
name: f.name.trim(),
|
||||
...(f.phone !== '' ? { phone: f.phone } : {}),
|
||||
...(f.email !== '' ? { email: f.email } : {}),
|
||||
}]
|
||||
: undefined
|
||||
createClient({
|
||||
name: f.name.trim(), stateCode: f.stateCode.trim(),
|
||||
...(f.gstin !== '' ? { gstin: f.gstin.trim().toUpperCase() } : {}),
|
||||
...(contact !== undefined ? { contacts: contact } : {}),
|
||||
})
|
||||
.then((c) => props.onCreated(c.id))
|
||||
.catch((e: Error) => setError(e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Field label="Name"><input className="wf" value={f.name} autoFocus onChange={set('name')} /></Field>
|
||||
<Field label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></Field>
|
||||
<Field label="GSTIN (optional)"><input className="wf" value={f.gstin} onChange={set('gstin')} /></Field>
|
||||
<Field label="Contact email"><input className="wf" value={f.email} onChange={set('email')} /></Field>
|
||||
<Field label="Contact phone"><input className="wf" value={f.phone} onChange={set('phone')} /></Field>
|
||||
</div>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Button tone="primary" onClick={save}>Save client</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { formatINR } from '@sims/domain'
|
||||
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, Toolbar } from '@sims/ui'
|
||||
import {
|
||||
documentAction, fetchPdfBlob, getClient, getDocumentFull,
|
||||
type Client, type Doc,
|
||||
} from '../api'
|
||||
import { DOC_TONE, useData } from './Clients'
|
||||
import { PaymentForm } from './ClientDetail'
|
||||
|
||||
const TITLE: Record<Doc['docType'], string> = {
|
||||
QUOTATION: 'Quotation', PROFORMA: 'Proforma Invoice', INVOICE: 'Tax Invoice',
|
||||
RECEIPT: 'Receipt', CREDIT_NOTE: 'Credit Note',
|
||||
}
|
||||
|
||||
/** Friendly text for the send-route's 409s (the spec's token-death banner). */
|
||||
function friendly(msg: string): string {
|
||||
if (msg === 'gmail-token-dead') return 'Gmail token dead — reconnect by running gmail-connect on the server, then retry.'
|
||||
if (msg === 'gmail-not-connected') return 'Gmail is not connected — run gmail-connect on the server first.'
|
||||
return msg
|
||||
}
|
||||
|
||||
/** Document page: PDF preview, state-driven action bar, event timeline + email log. */
|
||||
export function DocumentView() {
|
||||
const id = useParams()['id'] ?? ''
|
||||
const nav = useNavigate()
|
||||
const full = useData(() => getDocumentFull(id), [id])
|
||||
const doc = full.data?.document
|
||||
const client = useData<Client | undefined>(
|
||||
() => (doc !== undefined ? getClient(doc.clientId) : Promise.resolve(undefined)),
|
||||
[doc?.clientId],
|
||||
)
|
||||
const [actionErr, setActionErr] = useState<string | undefined>()
|
||||
const [busy, setBusy] = useState<string | undefined>()
|
||||
const [paying, setPaying] = useState(false)
|
||||
|
||||
// PDF preview via blob URL — an <iframe src> can't carry the bearer header.
|
||||
const [pdfUrl, setPdfUrl] = useState<string | undefined>()
|
||||
const [pdfErr, setPdfErr] = useState<string | undefined>()
|
||||
useEffect(() => {
|
||||
if (doc === undefined) return
|
||||
let alive = true
|
||||
let url: string | undefined
|
||||
setPdfErr(undefined)
|
||||
fetchPdfBlob(id)
|
||||
.then((blob) => {
|
||||
if (!alive) return
|
||||
url = URL.createObjectURL(blob)
|
||||
setPdfUrl(url)
|
||||
})
|
||||
.catch((e: Error) => { if (alive) setPdfErr(e.message) })
|
||||
return () => {
|
||||
alive = false
|
||||
if (url !== undefined) URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [id, doc?.docNo, doc?.status])
|
||||
|
||||
if (full.error !== undefined) return <Notice tone="err">{full.error}</Notice>
|
||||
if (doc === undefined || full.data === undefined) return <EmptyState>Loading…</EmptyState>
|
||||
|
||||
/** POST an action; convert/credit-note navigate to the new document. */
|
||||
const act = (action: string, body?: Record<string, unknown>, navigateToResult = false) => {
|
||||
setActionErr(undefined)
|
||||
setBusy(action)
|
||||
documentAction(id, action, body)
|
||||
.then((out) => {
|
||||
if (navigateToResult && out.id !== id) nav(`/documents/${out.id}`)
|
||||
else full.reload()
|
||||
})
|
||||
.catch((e: Error) => setActionErr(friendly(e.message)))
|
||||
.finally(() => setBusy(undefined))
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const defaultTo = client.data?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email ?? ''
|
||||
const to = window.prompt('Send to (email)', defaultTo)
|
||||
if (to === null || to.trim() === '') return
|
||||
const subject = window.prompt('Subject', `${doc.docType} ${doc.docNo ?? ''}`.trim())
|
||||
if (subject === null) return
|
||||
act('send', { to: to.trim(), subject })
|
||||
}
|
||||
|
||||
const issued = doc.docNo !== null
|
||||
const cancelled = doc.status === 'cancelled'
|
||||
const settling = doc.status === 'part_paid' || doc.status === 'paid'
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title={`${TITLE[doc.docType]} ${doc.docNo ?? '(draft)'}`}
|
||||
badge={doc.fy}
|
||||
desc={`${client.data?.name ?? '…'} · ${doc.docDate} · Payable ${formatINR(doc.payablePaise)}`}
|
||||
actions={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
|
||||
/>
|
||||
|
||||
<Toolbar>
|
||||
{!issued && !cancelled && (
|
||||
<Button tone="primary" onClick={() => act('issue')}>
|
||||
{busy === 'issue' ? 'Issuing…' : 'Issue number'}
|
||||
</Button>
|
||||
)}
|
||||
{issued && !cancelled && (
|
||||
<Button tone="primary" onClick={send}>{busy === 'send' ? 'Sending…' : 'Send by email'}</Button>
|
||||
)}
|
||||
{issued && doc.status === 'draft' && (
|
||||
<Button onClick={() => act('status', { status: 'sent' })}>Mark sent (manual)</Button>
|
||||
)}
|
||||
{doc.docType === 'QUOTATION' && doc.status === 'sent' && (
|
||||
<>
|
||||
<Button onClick={() => act('status', { status: 'accepted' })}>Mark accepted</Button>
|
||||
<Button onClick={() => act('status', { status: 'lost' })}>Mark lost</Button>
|
||||
</>
|
||||
)}
|
||||
{doc.docType === 'QUOTATION' && !cancelled && (
|
||||
<Button onClick={() => act('convert', { to: 'PROFORMA' }, true)}>Convert to Proforma</Button>
|
||||
)}
|
||||
{(doc.docType === 'QUOTATION' || doc.docType === 'PROFORMA') && !cancelled && (
|
||||
<Button onClick={() => act('convert', { to: 'INVOICE' }, true)}>Convert to Invoice</Button>
|
||||
)}
|
||||
{doc.docType === 'INVOICE' && issued && !cancelled && doc.status !== 'paid' && (
|
||||
<Button tone="primary" onClick={() => setPaying((v) => !v)}>Record payment</Button>
|
||||
)}
|
||||
{issued && !cancelled && !settling && (
|
||||
<Button
|
||||
tone="danger"
|
||||
onClick={() => { if (window.confirm(`Cancel ${doc.docNo}? The number stays consumed.`)) act('cancel') }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{doc.docType === 'INVOICE' && issued && !cancelled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Create a full-value credit note against ${doc.docNo}?`)) {
|
||||
act('credit-note', {}, true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Credit note
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
{actionErr !== undefined && <Notice tone="err">{actionErr}</Notice>}
|
||||
|
||||
{paying && (
|
||||
<PaymentForm
|
||||
clientId={doc.clientId}
|
||||
onDone={() => { setPaying(false); full.reload() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pdfErr !== undefined && <Notice tone="warn">PDF preview unavailable: {pdfErr}</Notice>}
|
||||
{pdfUrl !== undefined && (
|
||||
<iframe
|
||||
src={pdfUrl} title="Document PDF"
|
||||
style={{ width: '100%', height: 560, border: '1px solid var(--border)', borderRadius: 8, background: '#fff' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: 20 }}>Timeline</h3>
|
||||
{full.data.events.length === 0 ? <EmptyState>No events.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'at', label: 'When' }, { key: 'kind', label: 'Event' }, { key: 'meta', label: 'Detail' },
|
||||
]}
|
||||
rows={full.data.events.map((ev) => ({
|
||||
at: new Date(ev.atWall).toLocaleString(),
|
||||
kind: <Badge tone="accent">{ev.kind}</Badge>,
|
||||
meta: Object.keys(ev.meta).length > 0 ? JSON.stringify(ev.meta) : '—',
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: 20 }}>Email log</h3>
|
||||
{full.data.emails.length === 0 ? <EmptyState>Not emailed yet.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'at', label: 'When' }, { key: 'to', label: 'To' },
|
||||
{ key: 'subject', label: 'Subject' }, { key: 'status', label: 'Status' },
|
||||
{ key: 'error', label: 'Error' },
|
||||
]}
|
||||
rows={full.data.emails.map((e) => ({
|
||||
at: new Date(e.at_wall).toLocaleString(),
|
||||
to: e.to_addr, subject: e.subject,
|
||||
status: <Badge tone={e.status === 'sent' ? 'ok' : 'err'}>{e.status}</Badge>,
|
||||
error: e.error ?? '—',
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { formatINR, fromRupees } from '@sims/domain'
|
||||
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
|
||||
import {
|
||||
addPrice, createModule, getModules, getPrices, role,
|
||||
KIND_LABEL, type Kind, type Module,
|
||||
} from '../api'
|
||||
import { useData } from './Clients'
|
||||
|
||||
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
/** Module catalog + dated price book. Owner edits; staff read-only. */
|
||||
export function Modules() {
|
||||
const isOwner = role() === 'owner'
|
||||
const modules = useData(getModules, [])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selected, setSelected] = useState<Module | undefined>()
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="Modules"
|
||||
desc="What we sell — each with a dated price book (later rows win from their effective date)."
|
||||
actions={isOwner
|
||||
? <Button tone="primary" onClick={() => setCreating((v) => !v)}>New module</Button>
|
||||
: <Badge>read-only (staff)</Badge>}
|
||||
/>
|
||||
{creating && isOwner && (
|
||||
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
|
||||
)}
|
||||
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
|
||||
{modules.data === undefined ? <EmptyState>Loading…</EmptyState>
|
||||
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
|
||||
{ key: 'sac', label: 'SAC' }, { key: 'kinds', label: 'Billing kinds' },
|
||||
{ key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' },
|
||||
]}
|
||||
onRowClick={(_row, i) => setSelected(modules.data![i])}
|
||||
rows={modules.data.map((m) => ({
|
||||
code: m.code, name: m.name, sac: m.sac,
|
||||
kinds: m.allowedKinds.map((k) => KIND_LABEL[k]).join(' · '),
|
||||
multi: m.multiSubscription ? <Badge tone="accent">yes</Badge> : '—',
|
||||
active: m.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{selected !== undefined && (
|
||||
<PriceBook module={selected} isOwner={isOwner} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewModuleForm(props: { onCreated: () => void }) {
|
||||
const [f, setF] = useState({ code: '', name: '', sac: '998313' })
|
||||
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
|
||||
const [multi, setMulti] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
|
||||
setF((p) => ({ ...p, [k]: e.target.value }))
|
||||
const toggleKind = (k: Kind) =>
|
||||
setKinds((prev) => (prev.includes(k) ? prev.filter((x) => x !== k) : [...prev, k]))
|
||||
|
||||
const save = () => {
|
||||
if (f.code.trim() === '' || f.name.trim() === '') { setError('Code and name are required'); return }
|
||||
if (kinds.length === 0) { setError('Pick at least one billing kind'); return }
|
||||
createModule({
|
||||
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
|
||||
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
|
||||
})
|
||||
.then(props.onCreated)
|
||||
.catch((e: Error) => setError(e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<Field label="Code"><input className="wf" style={{ width: 100 }} value={f.code} autoFocus onChange={set('code')} /></Field>
|
||||
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
|
||||
<Field label="SAC"><input className="wf" style={{ width: 100 }} value={f.sac} onChange={set('sac')} /></Field>
|
||||
<Field label="Billing kinds">
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{ALL_KINDS.map((k) => (
|
||||
<label key={k} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" checked={kinds.includes(k)} onChange={() => toggleKind(k)} />
|
||||
{KIND_LABEL[k]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 13 }}>
|
||||
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
|
||||
multi-subscription
|
||||
</label>
|
||||
<Button tone="primary" onClick={save}>Save module</Button>
|
||||
</div>
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceBook(props: { module: Module; isOwner: boolean }) {
|
||||
const prices = useData(() => getPrices(props.module.id), [props.module.id])
|
||||
const [f, setF] = useState({ kind: '' as Kind | '', edition: 'standard', priceRs: '', effectiveFrom: today() })
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
|
||||
setF((p) => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const save = () => {
|
||||
const rs = Number(f.priceRs)
|
||||
if (f.kind === '') { setError('Pick a kind'); return }
|
||||
if (!Number.isFinite(rs) || rs < 0) { setError('Enter the price in rupees'); return }
|
||||
setError(undefined)
|
||||
addPrice(props.module.id, {
|
||||
kind: f.kind, edition: f.edition.trim() !== '' ? f.edition.trim() : 'standard',
|
||||
pricePaise: fromRupees(rs), effectiveFrom: f.effectiveFrom,
|
||||
})
|
||||
.then(() => { setF((p) => ({ ...p, priceRs: '' })); prices.reload() })
|
||||
.catch((e: Error) => setError(e.message))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 style={{ marginTop: 20 }}>Prices — {props.module.name}</h3>
|
||||
{props.isOwner && (
|
||||
<Toolbar>
|
||||
<select className="wf" value={f.kind} onChange={set('kind')}>
|
||||
<option value="">Kind…</option>
|
||||
{props.module.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
|
||||
</select>
|
||||
<input className="wf" style={{ width: 110 }} placeholder="edition" value={f.edition} onChange={set('edition')} />
|
||||
<input className="wf num" style={{ width: 110 }} placeholder="₹" value={f.priceRs} onChange={set('priceRs')} />
|
||||
<input type="date" className="wf" value={f.effectiveFrom} onChange={set('effectiveFrom')} />
|
||||
<Button tone="primary" onClick={save}>Add price</Button>
|
||||
</Toolbar>
|
||||
)}
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
{prices.error !== undefined && <Notice tone="err">{prices.error}</Notice>}
|
||||
{prices.data === undefined ? <EmptyState>Loading…</EmptyState>
|
||||
: prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first row above' : ''}.</EmptyState> : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'kind', label: 'Kind' }, { key: 'edition', label: 'Edition' },
|
||||
{ key: 'price', label: 'Price', numeric: true }, { key: 'from', label: 'Effective from' },
|
||||
]}
|
||||
rows={prices.data.map((p) => ({
|
||||
kind: KIND_LABEL[p.kind], edition: p.edition,
|
||||
price: formatINR(p.pricePaise), from: p.effectiveFrom,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef, useState } 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,
|
||||
KIND_LABEL, type Client, type Kind, type ModulePrice,
|
||||
} from '../api'
|
||||
import { useData } from './Clients'
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
interface LineDraft {
|
||||
moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
|
||||
}
|
||||
|
||||
const EMPTY_LINE: LineDraft = { moduleId: '', kind: '', qty: '1', unitRs: '', description: '' }
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** The quotation-in-minutes composer: client → lines → Save Draft (server computes GST). */
|
||||
export function NewDocument() {
|
||||
const nav = useNavigate()
|
||||
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)
|
||||
|
||||
// -- client type-ahead (debounced) --
|
||||
const [q, setQ] = useState('')
|
||||
const [hits, setHits] = useState<Client[]>([])
|
||||
const [client, setClient] = useState<Client | undefined>()
|
||||
useEffect(() => {
|
||||
if (client !== undefined || q.trim() === '') { setHits([]); return }
|
||||
const t = setTimeout(() => {
|
||||
getClients(q).then(setHits).catch(() => setHits([]))
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [q, client])
|
||||
|
||||
// -- per-module price cache for unit-₹ prefill --
|
||||
const priceCache = useRef(new Map<string, ModulePrice[]>())
|
||||
const prefill = (index: number, moduleId: string, kind: Kind) => {
|
||||
const apply = (prices: ModulePrice[]) => {
|
||||
const paise = latestPrice(prices, kind)
|
||||
setLines((prev) => prev.map((l, i) =>
|
||||
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l))
|
||||
}
|
||||
const cached = priceCache.current.get(moduleId)
|
||||
if (cached !== undefined) { apply(cached); return }
|
||||
getPrices(moduleId)
|
||||
.then((prices) => { priceCache.current.set(moduleId, prices); apply(prices) })
|
||||
.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)
|
||||
|
||||
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({
|
||||
docType,
|
||||
clientId: client.id,
|
||||
lines: ready.map((l) => ({
|
||||
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
|
||||
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
|
||||
...(l.description !== '' ? { description: l.description } : {}),
|
||||
})),
|
||||
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
|
||||
})
|
||||
.then((d) => nav(`/documents/${d.id}`))
|
||||
.catch((e: Error) => { setError(e.message); setSaving(false) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wf-page">
|
||||
<PageHeader
|
||||
title="New Document"
|
||||
desc="Compose a quotation, proforma or invoice. GST is computed server-side on save."
|
||||
/>
|
||||
|
||||
<Field label="Client">
|
||||
{client !== undefined ? (
|
||||
<Toolbar>
|
||||
<Badge tone="accent">{client.code} · {client.name}</Badge>
|
||||
<Button onClick={() => { setClient(undefined); setQ('') }}>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)}
|
||||
/>
|
||||
{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,
|
||||
}}>
|
||||
{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([]) }}
|
||||
>
|
||||
{c.code} · {c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Document type">
|
||||
<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)} />
|
||||
{t === 'QUOTATION' ? 'Quotation' : t === 'PROFORMA' ? 'Proforma' : 'Invoice'}
|
||||
</label>
|
||||
))}
|
||||
</Toolbar>
|
||||
</Field>
|
||||
|
||||
<h3>Lines</h3>
|
||||
{lines.map((l, i) => {
|
||||
const mod = modules.data?.find((m) => m.id === l.moduleId)
|
||||
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}
|
||||
onChange={(e) => {
|
||||
const moduleId = e.target.value
|
||||
setLine(i, { moduleId, kind: '', unitRs: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Module…</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)
|
||||
}}
|
||||
>
|
||||
<option value="">Kind…</option>
|
||||
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Qty">
|
||||
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Unit ₹ (ex-GST)">
|
||||
<input className="wf num" style={{ width: 120 }} value={l.unitRs} onChange={(e) => setLine(i, { unitRs: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Description (optional)">
|
||||
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} />
|
||||
</Field>
|
||||
{lines.length > 1 && (
|
||||
<Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Toolbar>
|
||||
<Button onClick={() => setLines((prev) => [...prev, { ...EMPTY_LINE }])}>Add line</Button>
|
||||
<Badge tone="accent">Subtotal {formatINR(subtotalPaise)} + GST (server-computed)</Badge>
|
||||
</Toolbar>
|
||||
|
||||
<Field label="Terms (optional)">
|
||||
<textarea
|
||||
className="wf" rows={2} style={{ maxWidth: 560 }}
|
||||
value={terms} onChange={(e) => setTerms(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
|
||||
{error !== undefined && <Notice tone="err">{error}</Notice>}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save Draft'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue