Files
Voiced/src/views/NewInvoice.tsx
T
xavierk a014a907e4 Rebuild the Classic template on the engine with A4/Letter and a template harness
Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
2026-10-04 05:31:14 +05:30

1117 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionableNotification,
Button,
Column,
Dropdown,
Grid,
InlineLoading,
InlineNotification,
Modal,
NumberInput,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tag,
TextArea,
TextInput,
Toggle,
} from "@carbon/react";
import {
Add,
Copy,
DocumentAdd,
Download,
Keyboard,
Renew,
Save,
TrashCan,
} from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type {
BankAccount,
Client,
DraftSummary,
Invoice,
InvoiceInput,
InvoiceItem,
LineMode,
LineUnit,
Settings,
TaxType,
} from "../lib/types";
import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import { InvoicePreview } from "../components/InvoicePreview";
import { ImagePicker } from "../components/ImagePicker";
import type { InvoicePdfProps } from "../lib/pdfProps";
interface NewInvoiceProps {
settings: Settings;
onSettingsChange: () => void;
/** Whether this view is the one on screen. It stays mounted while hidden. */
active: boolean;
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
onActivate: () => void;
}
const AUTOSAVE_MS = 1000;
const formatTime = (d: Date) => d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const formatStamp = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
};
export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) {
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const blankState = (): InvoiceFormState => {
const t = todayIso();
return {
invoiceDate: t,
dueDate: addDays(t, settings.paymentTermsDays),
clientId: null,
clientName: "",
clientAddress: "",
clientGstin: "",
poNumber: "",
placeOfSupply: settings.vendorStateCode,
saveClient: false,
items: [newItem()],
discount: 0,
taxRate: settings.defaultTaxRate,
reverseCharge: false,
bankId: settings.defaultBankId,
signaturePath: settings.signaturePath,
notes: "",
};
};
const today = todayIso();
const [invoiceDate, setInvoiceDate] = useState(today);
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
const [number, setNumber] = useState("");
const [clients, setClients] = useState<Client[]>([]);
const [clientId, setClientId] = useState<number | null>(null);
const [clientName, setClientName] = useState("");
const [clientAddress, setClientAddress] = useState("");
const [clientGstin, setClientGstin] = useState("");
const [poNumber, setPoNumber] = useState("");
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
const [saveClient, setSaveClient] = useState(false);
const [items, setItems] = useState<InvoiceItem[]>([newItem()]);
const [discount, setDiscount] = useState(0);
const registered = settings.gstRegistration !== "unregistered";
const [reverseCharge, setReverseCharge] = useState(false);
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
const [banks, setBanks] = useState<BankAccount[]>([]);
const [bankId, setBankId] = useState<number | null>(settings.defaultBankId);
const [signaturePath, setSignaturePath] = useState<string | null>(settings.signaturePath);
const [notes, setNotes] = useState("");
const [logoUri, setLogoUri] = useState<string | null>(null);
const [signatureUri, setSignatureUri] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
// form never issues again until the user starts a new one.
const [issued, setIssued] = useState<Invoice | null>(null);
const [exporting, setExporting] = useState(false);
const [exportedPath, setExportedPath] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const locked = issued !== null;
// Drafts.
const [draftId, setDraftId] = useState<number | null>(null);
const [baseline, setBaseline] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [draftSaving, setDraftSaving] = useState(false);
const [draftError, setDraftError] = useState<string | null>(null);
const [drafts, setDrafts] = useState<DraftSummary[]>([]);
const [noticeOpen, setNoticeOpen] = useState(false);
const [draftsOpen, setDraftsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
useEffect(() => {
let active = true;
(async () => {
try {
const [next, clientList, bankList, draftList] = await Promise.all([
api.peekNextInvoiceNumber(),
api.listClients(),
api.listBanks(),
api.listDrafts(),
]);
if (!active) return;
setNumber(next);
setClients(clientList);
setBanks(bankList);
setDrafts(draftList);
setNoticeOpen(draftList.length > 0);
} catch (e) {
if (active) setError(String(e));
}
})();
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
if (!settings.logoPath) {
setLogoUri(null);
return;
}
api.readAssetDataUri(settings.logoPath).then((uri) => active && setLogoUri(uri)).catch(() => {});
return () => {
active = false;
};
}, [settings.logoPath]);
useEffect(() => {
let active = true;
if (!signaturePath) {
setSignatureUri(null);
return;
}
api.readAssetDataUri(signaturePath).then((uri) => active && setSignatureUri(uri)).catch(() => {});
return () => {
active = false;
};
}, [signaturePath]);
// Rust derives the tax type from the supplier state and the place of supply; this
// mirrors that rule so the preview and the value sent for the cross-check agree.
const effectiveTaxType: TaxType = !registered
? "none"
: !placeOfSupply || placeOfSupply === settings.vendorStateCode
? "cgst_sgst"
: "igst";
const clientGstinError = gstinFullError(clientGstin);
const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate],
);
const words = useMemo(() => amountInWords(totals.total), [totals.total]);
const bank = useMemo(() => banks.find((b) => b.id === bankId) ?? null, [banks, bankId]);
const payload = useMemo(
() =>
toDraftPayload({
invoiceDate,
dueDate,
clientId,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupply,
saveClient,
items,
discount,
taxRate,
reverseCharge,
bankId,
signaturePath,
notes,
}),
[
invoiceDate,
dueDate,
clientId,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupply,
saveClient,
items,
discount,
taxRate,
reverseCharge,
bankId,
signaturePath,
notes,
],
);
const snapshot = useMemo(() => JSON.stringify(payload), [payload]);
// The untouched form is the baseline; anything else is dirty. A pristine form is never saved.
useEffect(() => {
setBaseline((current) => current ?? snapshot);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dirty = baseline !== null && snapshot !== baseline;
const unsaved = !locked && dirty && snapshot !== savedSnapshot;
const latest = useRef({ snapshot, payload });
latest.current = { snapshot, payload };
const draftIdRef = useRef<number | null>(null);
const savedRef = useRef<string | null>(null);
const issuedRef = useRef(false);
const chain = useRef<Promise<void>>(Promise.resolve());
const refreshDrafts = useCallback(async () => {
try {
setDrafts(await api.listDrafts());
} catch {
/* the list is a convenience; ignore */
}
}, []);
const refreshNumber = async () => {
try {
setNumber(await api.peekNextInvoiceNumber());
} catch {
/* ignore */
}
};
// Saves are serialised so a slow first save cannot race a second one into creating two drafts.
const persistDraft = useCallback((): Promise<void> => {
const run = async () => {
if (issuedRef.current) return;
const { snapshot: snap, payload: body } = latest.current;
if (snap === savedRef.current) return;
setDraftSaving(true);
try {
const id = await api.saveDraft(draftIdRef.current, body);
draftIdRef.current = id;
savedRef.current = snap;
setDraftId(id);
setSavedSnapshot(snap);
setSavedAt(new Date());
setDraftError(null);
} finally {
setDraftSaving(false);
}
};
const next = chain.current.then(run);
chain.current = next.catch(() => {});
return next;
}, []);
useEffect(() => {
if (!unsaved) return;
const timer = setTimeout(() => {
persistDraft()
.then(refreshDrafts)
.catch((e) => setDraftError(String(e)));
}, AUTOSAVE_MS);
return () => clearTimeout(timer);
}, [unsaved, snapshot, persistDraft, refreshDrafts]);
useEffect(() => {
if (!unsaved) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [unsaved]);
const previewProps: InvoicePdfProps = {
vendor: settings,
bank,
logoDataUri: logoUri,
signatureDataUri: signatureUri,
number: issued?.number ?? (number || "DRAFT"),
invoiceDate,
dueDate,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
items,
subtotal: totals.subtotal,
discount: totals.discount,
taxType: effectiveTaxType,
taxRate,
cgst: totals.cgst,
sgst: totals.sgst,
igst: totals.igst,
total: totals.total,
amountInWords: words,
notes,
};
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
const addItem = () => setItems((prev) => [...prev, newItem()]);
const removeItem = (index: number) =>
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
const applyClient = (selected: Client) => {
setClientId(selected.id);
setClientName(selected.name);
setClientAddress(selected.address);
setClientGstin(selected.gstin);
setPoNumber(selected.poNumber);
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
};
const applyState = (s: InvoiceFormState) => {
setInvoiceDate(s.invoiceDate);
setDueDate(s.dueDate);
setClientId(s.clientId);
setClientName(s.clientName);
setClientAddress(s.clientAddress);
setClientGstin(s.clientGstin);
setPoNumber(s.poNumber);
setPlaceOfSupply(s.placeOfSupply);
setSaveClient(s.saveClient);
setItems(s.items);
setDiscount(s.discount);
setTaxRate(s.taxRate);
setReverseCharge(s.reverseCharge);
setBankId(s.bankId);
setSignaturePath(s.signaturePath);
setNotes(s.notes);
};
const clearIssued = () => {
issuedRef.current = false;
setIssued(null);
setExportedPath(null);
setExportError(null);
setError(null);
};
const detachDraft = () => {
draftIdRef.current = null;
savedRef.current = null;
setDraftId(null);
setSavedSnapshot(null);
setSavedAt(null);
setDraftError(null);
};
const resetForm = () => {
const blank = blankState();
applyState(blank);
setBaseline(JSON.stringify(toDraftPayload(blank)));
clearIssued();
detachDraft();
void refreshNumber();
api.listClients().then(setClients).catch(() => {});
void refreshDrafts();
};
// Keeps the work in a draft before it is replaced. Returns false if that was not possible
// and the user chose to keep the current form.
const parkCurrentWork = async (): Promise<boolean> => {
if (locked || !unsaved) return true;
try {
await persistDraft();
return true;
} catch (e) {
return confirm(`The current form could not be saved as a draft (${String(e)}). Discard it?`, {
title: "Unsaved changes",
kind: "warning",
});
}
};
const newInvoice = async () => {
onActivate();
if (!(await parkCurrentWork())) return;
resetForm();
};
const duplicateAsNew = () => {
const t = todayIso();
clearIssued();
detachDraft();
setInvoiceDate(t);
setDueDate(addDays(t, settings.paymentTermsDays));
setSaveClient(false);
void refreshNumber();
};
const saveDraftNow = async () => {
if (locked || !dirty) return;
try {
await persistDraft();
await refreshDrafts();
} catch (e) {
setDraftError(String(e));
}
};
const discardDraft = async () => {
const ok = await confirm(
draftId !== null
? "Discard this draft and clear the form?"
: "Clear the form? Nothing has been saved as a draft.",
{ title: "Discard draft", kind: "warning" },
);
if (!ok) return;
// Wait for any in-flight save so it cannot recreate the draft after the delete.
await chain.current;
const id = draftIdRef.current;
try {
if (id !== null) await api.deleteDraft(id);
} catch (e) {
setDraftError(String(e));
return;
}
resetForm();
};
const resumeDraft = async (id: number) => {
if (id === draftIdRef.current) {
setDraftsOpen(false);
return;
}
if (!(await parkCurrentWork())) return;
try {
const restored = fromDraftPayload(await api.getDraft(id), blankState());
if (restored.bankId !== null && !banks.some((b) => b.id === restored.bankId)) {
restored.bankId = null;
}
applyState(restored);
setBaseline(JSON.stringify(toDraftPayload(blankState())));
clearIssued();
const snap = JSON.stringify(toDraftPayload(restored));
draftIdRef.current = id;
savedRef.current = snap;
setDraftId(id);
setSavedSnapshot(snap);
setSavedAt(null);
setDraftError(null);
setNoticeOpen(false);
setDraftsOpen(false);
void refreshNumber();
void refreshDrafts();
} catch (e) {
setError(String(e));
}
};
const removeDraft = async (id: number) => {
try {
await api.deleteDraft(id);
} catch (e) {
setError(String(e));
}
await refreshDrafts();
};
const runExport = async (invoice: Invoice) => {
setExporting(true);
setExportError(null);
try {
const path = await exportInvoicePdf(invoice, settings, bank);
setExportedPath(path ?? null);
} catch (e) {
setExportError(String(e));
} finally {
setExporting(false);
}
};
const onSave = async () => {
if (locked || saving) return;
if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
setSaving(true);
setError(null);
setExportedPath(null);
setExportError(null);
let invoice: Invoice;
try {
// Let a pending autosave land first so the issue call knows which draft to delete.
await chain.current;
issuedRef.current = true;
const input: InvoiceInput = {
invoiceDate,
dueDate,
clientId,
clientName,
clientAddress,
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
discount,
taxType: effectiveTaxType,
taxRate,
reverseCharge: registered && reverseCharge,
bankAccountId: bankId,
signaturePath,
notes,
saveClient,
draftId: draftIdRef.current,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
};
invoice = await api.issueInvoice(input, {});
} catch (e) {
issuedRef.current = false;
setError(String(e));
setSaving(false);
return;
}
// The number is consumed from here on. A failed export must never lead to a second issue.
setIssued(invoice);
detachDraft();
api.listClients().then(setClients).catch(() => {});
void refreshDrafts();
await runExport(invoice);
setSaving(false);
};
useShortcuts({
onNew: () => void newInvoice(),
onSaveDraft: () => active && void saveDraftNow(),
onIssue: () => active && !locked && void onSave(),
onAddLine: () => active && !locked && addItem(),
onHelp: () => active && setHelpOpen(true),
});
const otherDrafts = drafts.filter((d) => d.id !== draftId);
const busy = saving || exporting;
return (
<>
<div className="voiced-page" hidden={!active}>
<Grid>
<Column sm={4} md={8} lg={16}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "1rem", flexWrap: "wrap" }}>
<div>
<h1 className="voiced-page__title">{locked ? `Invoice ${issued.number}` : "New invoice"}</h1>
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
{locked ? (
"Issued and read-only. Use the actions below to continue."
) : (
<>
Next number: <strong>{number || "—"}</strong>
</>
)}
</p>
</div>
{locked ? null : (
<div className="voiced-header-action" style={{ flexWrap: "wrap" }}>
{draftSaving ? (
<InlineLoading status="active" description="Saving draft…" />
) : savedAt && !unsaved ? (
<InlineLoading status="finished" description={`Draft saved ${formatTime(savedAt)}`} />
) : unsaved ? (
<Tag type="gray" size="sm">
Unsaved changes
</Tag>
) : null}
{drafts.length > 0 ? (
<Button kind="ghost" onClick={() => setDraftsOpen(true)} disabled={busy}>
{`Drafts (${drafts.length})`}
</Button>
) : null}
<Button kind="ghost" renderIcon={Keyboard} hasIconOnly iconDescription="Keyboard shortcuts (?)" tooltipPosition="bottom" onClick={() => setHelpOpen(true)} />
{dirty || draftId !== null ? (
<Button kind="ghost" onClick={discardDraft} disabled={busy}>
Discard draft
</Button>
) : null}
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
Save draft
</Button>
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
{saving ? "Saving…" : "Save & export PDF"}
</Button>
</div>
)}
</div>
{error ? (
<InlineNotification
kind="error"
lowContrast
title="Could not save invoice"
subtitle={error}
onCloseButtonClick={() => setError(null)}
/>
) : null}
{draftError ? (
<InlineNotification
kind="warning"
lowContrast
title="Draft not saved"
subtitle={draftError}
onCloseButtonClick={() => setDraftError(null)}
/>
) : null}
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
<ActionableNotification
inline
kind="info"
lowContrast
title={`You have ${otherDrafts.length} unfinished draft${otherDrafts.length === 1 ? "" : "s"}`}
subtitle="Pick up where you left off."
actionButtonLabel="Resume latest"
onActionButtonClick={() => void resumeDraft(otherDrafts[0].id)}
onCloseButtonClick={() => setNoticeOpen(false)}
/>
) : null}
{locked ? (
<>
{exportError ? (
<InlineNotification
kind="error"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
/>
) : exportedPath ? (
<InlineNotification
kind="success"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle={exportedPath}
/>
) : exporting ? (
<InlineLoading description="Exporting PDF…" />
) : (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle="The PDF was not saved. Use Export again to save it."
/>
)}
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<Button
kind={exportError ? "primary" : "tertiary"}
renderIcon={exportError ? Renew : Download}
onClick={() => void runExport(issued)}
disabled={exporting}
>
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
</Button>
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
Duplicate as new
</Button>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
New invoice
</Button>
</div>
</>
) : null}
<div style={{ marginTop: "1.5rem" }} />
</Column>
<Column sm={4} md={8} lg={9} className="voiced-stack">
<Grid narrow>
<Column sm={4} md={4} lg={5}>
<TextInput
id="invoiceDate"
disabled={locked}
type="date"
labelText="Invoice date"
value={invoiceDate}
onChange={(e) => {
setInvoiceDate(e.target.value);
setDueDate(addDays(e.target.value, settings.paymentTermsDays));
}}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="dueDate"
disabled={locked}
type="date"
labelText={`Due date (net ${settings.paymentTermsDays} days)`}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<TextInput id="invoiceNumber" labelText="Invoice number" value={issued?.number ?? number} readOnly />
</Column>
<Column sm={4} md={8} lg={8}>
<Dropdown
id="savedClient"
disabled={locked}
titleText="Saved client (optional)"
label="Select a saved client to autofill"
items={clients}
itemToString={(item) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<TextInput
id="clientName"
disabled={locked}
labelText="Client name"
value={clientName}
onChange={(e) => {
setClientName(e.target.value);
setClientId(null);
}}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextArea
id="clientAddress"
disabled={locked}
labelText="Client address"
rows={3}
value={clientAddress}
onChange={(e) => setClientAddress(e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="clientGstin"
disabled={locked}
labelText="Client GSTIN (optional)"
value={clientGstin}
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
invalid={Boolean(clientGstinError)}
invalidText={clientGstinError}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="poNumber"
disabled={locked}
labelText="PO number (optional)"
value={poNumber}
onChange={(e) => setPoNumber(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="placeOfSupply"
disabled={locked}
labelText="Place of supply"
value={placeOfSupply}
onChange={(e) => setPlaceOfSupply(e.target.value)}
>
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="saveClient"
disabled={locked}
labelText="Save this client for next time"
toggled={saveClient}
onToggle={setSaveClient}
/>
</Column>
</Grid>
<div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
{items.map((item, index) => (
<div
key={index}
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "flex-end",
padding: "0.75rem",
border: "1px solid var(--cds-border-subtle-01)",
marginBottom: "0.5rem",
}}
>
<div style={{ flex: "3 1 240px" }}>
<TextInput
id={`desc-${index}`}
disabled={locked}
labelText="Particulars / description"
value={item.description}
onChange={(e) => updateItem(index, { description: e.target.value })}
/>
</div>
{registered ? (
<div style={{ flex: "0 1 120px" }}>
<TextInput
id={`hsn-${index}`}
disabled={locked}
labelText="HSN/SAC"
value={item.hsnSac ?? ""}
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
/>
</div>
) : null}
<div style={{ flex: "0 1 150px" }}>
<Select
id={`mode-${index}`}
disabled={locked}
labelText="Type"
value={item.mode}
onChange={(e) => updateItem(index, { mode: e.target.value as LineMode })}
>
<SelectItem value="fixed" text="Fixed amount" />
<SelectItem value="rate" text="Rate × quantity" />
</Select>
</div>
{item.mode === "rate" ? (
<>
<div style={{ flex: "0 1 130px" }}>
<NumberInput
id={`rate-${index}`}
disabled={locked}
label="Rate (₹)"
min={0}
step={1}
value={item.rate}
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/>
</div>
<div style={{ flex: "0 1 140px" }}>
<Select
id={`unit-${index}`}
disabled={locked}
labelText="Unit"
value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</div>
<div style={{ flex: "0 1 110px" }}>
<NumberInput
id={`qty-${index}`}
disabled={locked}
label="Quantity"
min={0}
step={1}
value={item.quantity}
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })}
/>
</div>
</>
) : (
<div style={{ flex: "0 1 150px" }}>
<NumberInput
id={`amount-${index}`}
disabled={locked}
label="Amount (₹)"
min={0}
step={1}
value={item.amount}
onChange={(_e, { value }) => updateItem(index, { amount: Number(value) || 0 })}
/>
</div>
)}
<div style={{ flex: "0 0 auto", paddingBottom: "0.4rem" }}>
<strong>₹{formatAmount(computeLineAmount(item))}</strong>
</div>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription="Remove line"
onClick={() => removeItem(index)}
disabled={locked || items.length === 1}
/>
</div>
))}
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={addItem} disabled={locked}>
Add line
</Button>
</div>
<Grid narrow>
<Column sm={4} md={4} lg={8}>
<NumberInput
id="discount"
disabled={locked}
label="Discount (₹)"
min={0}
step={1}
value={discount}
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
/>
</Column>
{registered ? (
<>
<Column sm={4} md={8} lg={16}>
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
{effectiveTaxType === "igst"
? "IGST (inter-state supply)"
: "CGST + SGST (supply within the supplier's state)"}
</p>
</Column>
<Column sm={4} md={4} lg={8}>
<NumberInput
id="taxRate"
disabled={locked}
label="Tax rate (%)"
min={0}
max={100}
step={0.5}
value={taxRate}
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="reverseCharge"
disabled={locked}
labelText="Tax payable on reverse charge"
toggled={reverseCharge}
onToggle={setReverseCharge}
/>
</Column>
</>
) : null}
</Grid>
<Select
id="bankAccount"
disabled={locked}
labelText="Bank account (Pay To)"
value={bankId ?? ""}
onChange={(e) => setBankId(e.target.value ? Number(e.target.value) : null)}
>
<SelectItem value="" text="No bank details" />
{banks.map((b) => (
<SelectItem
key={b.id ?? 0}
value={String(b.id)}
text={`${b.bankName} · ${b.accountNo}`}
/>
))}
</Select>
<ImagePicker
kind="signature"
label="Signature for this invoice (optional)"
value={signaturePath}
onChange={setSignaturePath}
disabled={locked}
/>
<TextArea
id="notes"
disabled={locked}
labelText="Notes (optional)"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={7}>
<div style={{ position: "sticky", top: "3rem" }}>
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
<InvoicePreview {...previewProps} />
</div>
</Column>
</Grid>
</div>
<Modal
open={draftsOpen}
passiveModal
size="md"
modalHeading="Unfinished drafts"
onRequestClose={() => setDraftsOpen(false)}
>
<Table size="md" aria-label="Drafts">
<TableHead>
<TableRow>
<TableHeader>Client</TableHeader>
<TableHeader>Last edited</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{drafts.map((d) => (
<TableRow key={d.id}>
<TableCell>{d.clientName || "—"}</TableCell>
<TableCell>{formatStamp(d.updatedAt)}</TableCell>
<TableCell>
{d.id === draftId ? (
<Tag type="blue" size="sm">
Open now
</Tag>
) : (
<div className="voiced-header-action">
<Button kind="ghost" size="sm" onClick={() => void resumeDraft(d.id)}>
Resume
</Button>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription="Delete draft"
onClick={() => void removeDraft(d.id)}
/>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{drafts.length === 0 ? <p style={{ marginTop: "1rem" }}>No drafts.</p> : null}
</Modal>
<Modal
open={helpOpen}
passiveModal
size="sm"
modalHeading="Keyboard shortcuts"
onRequestClose={() => setHelpOpen(false)}
>
<Table size="md" aria-label="Keyboard shortcuts">
<TableHead>
<TableRow>
<TableHeader>Keys</TableHeader>
<TableHeader>Action</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{SHORTCUT_LIST.map((row) => (
<TableRow key={row.keys}>
<TableCell>
<strong>{row.keys}</strong>
</TableCell>
<TableCell>{row.action}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Modal>
</>
);
}