Files
Voiced/src/views/NewInvoice.tsx
T
xavierk 3591bb9f7d Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)
- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
2026-10-04 20:57:31 +05:30

1223 lines
44 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 { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionableNotification,
Button,
ComboBox,
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,
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,
ItemPreset,
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 { modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo";
import ClientModal from "../components/ClientModal";
import { useToast } from "../components/ToastProvider";
import { itemFromPreset } from "../lib/presets";
// pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview"));
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;
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
openDraftId?: number | null;
onDraftOpened?: () => 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, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
const toast = useToast();
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 [presets, setPresets] = useState<ItemPreset[]>([]);
const [clientModalOpen, setClientModalOpen] = useState(false);
const newClientButton = useRef<HTMLButtonElement | null>(null);
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 [logoImages, setLogoImages] = useState<LogoImages | 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);
// What the backend recorded when the issued PDF was archived; null until that worked.
const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null);
const [archiveError, setArchiveError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
const locked = issued !== null;
// The issued invoice as the export flow needs it: with its archive fields once archived.
const exportable = useMemo<Invoice | null>(
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
[issued, archive],
);
// 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) toast.error("Could not load invoice data", String(e));
}
})();
return () => {
active = false;
};
}, []);
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
useEffect(() => {
if (!active) return;
api.listClients().then(setClients).catch(() => {});
api.listItemPresets().then(setPresets).catch(() => {});
}, [active]);
useEffect(() => {
let active = true;
if (!settings.logoPath) {
setLogoImages(null);
return;
}
loadLogoImages(settings, api.readAssetDataUri)
.then((images) => active && setLogoImages(images))
.catch(() => {});
return () => {
active = false;
};
}, [settings.logoPath, settings.logoKnockoutPath]);
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;
}, []);
// A failed autosave is reported once per failure streak (the message clears on the next success).
useEffect(() => {
if (draftError) toast.warning("Draft not saved", draftError);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftError]);
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]);
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
// setupPrefs is the page setup as edited (and saved as the default); activePrefs adds the imported fonts the template
// draws, which is what renders and what is frozen on the invoice at issue. An issued invoice keeps its frozen fonts.
const userFonts = useUserFonts();
const setupPrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
const activePrefs = useMemo(() => (issued ? setupPrefs : withUserFonts(setupPrefs, userFonts)), [issued, setupPrefs, userFonts]);
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
const previewModel = useMemo(() => {
if (!active) return null;
const invoice =
issued ??
formToPreviewInvoice(
{ invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items },
number || "DRAFT",
settings,
bank,
);
return modelFromAssets(invoice, settings, bank, logoImages, signatureUri);
}, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoImages, signatureUri]);
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);
// Client defaults fill gaps only: typed notes are never overwritten.
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
};
const addPreset = (preset: ItemPreset) => {
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
};
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);
setArchive(null);
setArchiveError(null);
exp.dismiss();
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) {
toast.error("Could not open the draft", String(e));
}
};
useEffect(() => {
if (openDraftId === null || !active) return;
onDraftOpened?.();
void resumeDraft(openDraftId);
// resumeDraft closes over fresh state each render; only a new request should trigger it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openDraftId, active]);
const removeDraft = async (id: number) => {
try {
await api.deleteDraft(id);
} catch (e) {
toast.error("Could not delete the draft", String(e));
}
await refreshDrafts();
};
const noteArchived = (invoice: Invoice) => {
if (invoice.archivedPdfSha256) {
setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" });
setArchiveError(null);
}
};
const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived });
const retryArchive = async () => {
if (!exportable || archiving) return;
setArchiving(true);
const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank }));
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
setArchiving(false);
};
const onSave = async () => {
if (locked || saving) return;
if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
setSaving(true);
setError(null);
exp.dismiss();
const mode = loadExportMode();
let outcome: Awaited<ReturnType<typeof issueAndArchive>>;
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 })),
};
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) {
issuedRef.current = false;
toast.error("Could not issue the invoice", String(e));
setSaving(false);
return;
}
// The number is consumed from here on. A failed archive or export must never lead to a second issue.
setIssued(outcome.invoice);
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
detachDraft();
api.listClients().then(setClients).catch(() => {});
void refreshDrafts();
// "Send on issue": in the background, so it never delays or fails the issue; the result arrives as a toast.
void autoPushIssued(
{ getConfig: api.erpnextGetConfig, push: api.erpnextPushInvoice },
outcome.invoice,
settings.gstRegistration !== "unregistered",
)
.then((note) => {
if (note) toast[note.kind](note.title, note.subtitle);
})
.catch(() => {});
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
await exp.start(outcome.invoice, mode, "original");
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 || exp.busy || archiving;
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
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>
<div className="voiced-export-split">
<Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
{saving ? "Issuing…" : "Issue & export"}
</Button>
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
</div>
</div>
)}
</div>
{error ? (
<InlineNotification
kind="error"
lowContrast
title="Fix this before issuing"
subtitle={error}
onCloseButtonClick={() => setError(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 && exportable ? (
<>
{!exportable.archivedPdfSha256 ? (
<ActionableNotification
inline
kind="warning"
lowContrast
hideCloseButton
title="Issued — PDF not archived yet"
subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
onActionButtonClick={() => void retryArchive()}
/>
) : null}
<ExportFeedback exp={exp} />
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
Duplicate as new
</Button>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
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}>
<ComboBox
id="savedClient"
disabled={locked}
titleText="Saved client (optional)"
placeholder="Type to search saved clients"
items={clients}
itemToString={(item: Client | null) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null}
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
const q = (inputValue ?? "").trim().toLowerCase();
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
}}
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
/>
<Button
ref={newClientButton}
kind="ghost"
size="sm"
renderIcon={Add}
disabled={locked}
onClick={() => setClientModalOpen(true)}
>
Create client
</Button>
</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>
{presets.length > 0 ? (
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
<Dropdown
id="addFromPreset"
disabled={locked}
titleText="Add from preset"
label="Choose a saved item"
items={presets}
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
selectedItem={null}
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
/>
</div>
) : null}
{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>
<PageSetupControls prefs={setupPrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
<PdfPreview
bytes={pdf.bytes}
pages={pdf.pages}
issues={pdf.issues}
rendering={pdf.rendering}
mode={pdf.mode}
error={pdf.error}
/>
</Suspense>
</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>
<ClientModal
open={clientModalOpen}
client={null}
launcherRef={newClientButton}
onClose={() => setClientModalOpen(false)}
onSaved={(saved) => {
setClientModalOpen(false);
applyClient(saved);
api.listClients().then(setClients).catch(() => {});
}}
/>
</>
);
}