From fbed18a0bd52104d82ef6f50bab1dcd1ad8bdc6b Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 04:21:47 +0530 Subject: [PATCH] Add invoice drafts, read-only issued state and keyboard shortcuts Views stay mounted so switching tabs no longer loses a half-typed invoice. The form autosaves a draft after 1 s, drafts can be resumed or deleted, and after issuing the form is read-only with Export again, Duplicate and New invoice actions. A failed export keeps the issued invoice and offers a retry instead of issuing again. Adds Ctrl+N/S/Enter/L and a shortcut sheet; F5 and Ctrl+R are blocked in production builds. --- src/App.tsx | 16 +- src/hooks/useShortcuts.ts | 62 ++++ src/lib/draft.test.ts | 72 ++++ src/lib/draft.ts | 92 +++++ src/styles/carbon.scss | 5 + src/views/InvoiceHistory.tsx | 7 +- src/views/NewInvoice.tsx | 652 ++++++++++++++++++++++++++++++++--- src/views/SeriesSettings.tsx | 15 +- 8 files changed, 859 insertions(+), 62 deletions(-) create mode 100644 src/hooks/useShortcuts.ts create mode 100644 src/lib/draft.test.ts create mode 100644 src/lib/draft.ts diff --git a/src/App.tsx b/src/App.tsx index c6e4b0e..eafa28a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,9 +88,19 @@ export default function App() { return ( - {view === "new" && } - {view === "invoices" && } - {view === "series" && } + {/* Main views stay mounted so a half-typed invoice survives switching tabs. */} + setView("new")} + /> + + {view === "settings" && } diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts new file mode 100644 index 0000000..b954c6b --- /dev/null +++ b/src/hooks/useShortcuts.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; + +export interface ShortcutHandlers { + onNew?: () => void; + onSaveDraft?: () => void; + onIssue?: () => void; + onAddLine?: () => void; + onHelp?: () => void; +} + +const isTyping = (target: EventTarget | null) => { + const el = target as HTMLElement | null; + if (!el) return false; + return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable; +}; + +/** + * Global keyboard shortcuts. Ctrl combinations work everywhere; the bare `?` is ignored + * while typing. F5 / Ctrl+R are blocked in production builds because a webview reload + * would discard in-memory state. + */ +export function useShortcuts(handlers: ShortcutHandlers) { + const ref = useRef(handlers); + ref.current = handlers; + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const ctrl = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + + if (import.meta.env.PROD && (event.key === "F5" || (ctrl && key === "r"))) { + event.preventDefault(); + return; + } + + const run = (fn?: () => void) => { + event.preventDefault(); + fn?.(); + }; + + if (ctrl && !event.shiftKey && !event.altKey) { + if (key === "n") return run(ref.current.onNew); + if (key === "s") return run(ref.current.onSaveDraft); + if (key === "l") return run(ref.current.onAddLine); + if (key === "enter") return run(ref.current.onIssue); + } + if (!ctrl && !event.altKey && event.key === "?" && !isTyping(event.target)) { + run(ref.current.onHelp); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} + +export const SHORTCUT_LIST: { keys: string; action: string }[] = [ + { keys: "Ctrl + N", action: "New invoice" }, + { keys: "Ctrl + S", action: "Save draft" }, + { keys: "Ctrl + Enter", action: "Issue and export PDF" }, + { keys: "Ctrl + L", action: "Add line item" }, + { keys: "?", action: "Show this list" }, +]; diff --git a/src/lib/draft.test.ts b/src/lib/draft.test.ts new file mode 100644 index 0000000..7c2634f --- /dev/null +++ b/src/lib/draft.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "./draft"; +import { EMPTY_ITEM } from "./types"; + +const defaults: InvoiceFormState = { + invoiceDate: "2026-01-01", + dueDate: "2026-01-31", + clientId: null, + clientName: "", + clientAddress: "", + clientGstin: "", + poNumber: "", + placeOfSupply: "29", + saveClient: false, + items: [{ ...EMPTY_ITEM, hsnSac: "9983" }], + discount: 0, + taxRate: 18, + reverseCharge: false, + bankId: 2, + signaturePath: "/sig/default.png", + notes: "", +}; + +const filled: InvoiceFormState = { + invoiceDate: "2026-03-05", + dueDate: "2026-04-04", + clientId: 7, + clientName: "Acme Pvt Ltd", + clientAddress: "1 Main Rd\nKolkata", + clientGstin: "19AAAAA0000A1Z5", + poNumber: "PO-9", + placeOfSupply: "19", + saveClient: true, + items: [ + { ...EMPTY_ITEM, description: "Design", mode: "rate", rate: 500, unit: "hour", quantity: 4, hsnSac: "9983" }, + { ...EMPTY_ITEM, description: "Fee", amount: 1200, sortOrder: 1, hsnSac: "9983" }, + ], + discount: 100, + taxRate: 12, + reverseCharge: true, + bankId: null, + signaturePath: null, + notes: "Thanks", +}; + +describe("draft payload", () => { + it("round-trips through JSON", () => { + const json = JSON.parse(JSON.stringify(toDraftPayload(filled))); + expect(fromDraftPayload(json, defaults)).toEqual(filled); + }); + + it("does not store derived totals", () => { + const keys = Object.keys(toDraftPayload(filled)); + for (const k of ["total", "subtotal", "cgst", "sgst", "igst", "amountInWords"]) { + expect(keys).not.toContain(k); + } + }); + + it("falls back to defaults for missing fields", () => { + const out = fromDraftPayload({ clientName: "Only name" }, defaults); + expect(out).toEqual({ ...defaults, clientName: "Only name" }); + }); + + it("tolerates garbage input and partial items", () => { + expect(fromDraftPayload(null, defaults)).toEqual(defaults); + expect(fromDraftPayload("x", defaults)).toEqual(defaults); + const out = fromDraftPayload({ items: [{ description: "A" }], discount: "oops" }, defaults); + expect(out.discount).toBe(0); + expect(out.items).toHaveLength(1); + expect(out.items[0]).toMatchObject({ description: "A", mode: "fixed", quantity: 1, hsnSac: "" }); + }); +}); diff --git a/src/lib/draft.ts b/src/lib/draft.ts new file mode 100644 index 0000000..30cc926 --- /dev/null +++ b/src/lib/draft.ts @@ -0,0 +1,92 @@ +import { EMPTY_ITEM } from "./types"; +import type { InvoiceItem, LineMode, LineUnit } from "./types"; + +/** Everything that defines an invoice on the New invoice form. Derived totals are not part of it. */ +export interface InvoiceFormState { + invoiceDate: string; + dueDate: string; + clientId: number | null; + clientName: string; + clientAddress: string; + clientGstin: string; + poNumber: string; + placeOfSupply: string; + saveClient: boolean; + items: InvoiceItem[]; + discount: number; + taxRate: number; + reverseCharge: boolean; + bankId: number | null; + signaturePath: string | null; + notes: string; +} + +const str = (v: unknown, fallback: string) => (typeof v === "string" ? v : fallback); +const num = (v: unknown, fallback: number) => + typeof v === "number" && Number.isFinite(v) ? v : fallback; +const bool = (v: unknown, fallback: boolean) => (typeof v === "boolean" ? v : fallback); +const nullableNum = (v: unknown, fallback: number | null) => + v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : fallback; + +function itemFromJson(raw: unknown, index: number): InvoiceItem { + const r = (raw && typeof raw === "object" ? raw : {}) as Record; + return { + description: str(r.description, ""), + mode: (r.mode === "rate" ? "rate" : "fixed") as LineMode, + rate: num(r.rate, EMPTY_ITEM.rate), + unit: str(r.unit, EMPTY_ITEM.unit) as LineUnit, + quantity: num(r.quantity, EMPTY_ITEM.quantity), + amount: num(r.amount, EMPTY_ITEM.amount), + sortOrder: index, + hsnSac: str(r.hsnSac, ""), + }; +} + +export function toDraftPayload(state: InvoiceFormState): Record { + return { + version: 1, + invoiceDate: state.invoiceDate, + dueDate: state.dueDate, + clientId: state.clientId, + clientName: state.clientName, + clientAddress: state.clientAddress, + clientGstin: state.clientGstin, + poNumber: state.poNumber, + placeOfSupply: state.placeOfSupply, + saveClient: state.saveClient, + items: state.items.map((item, index) => ({ ...item, sortOrder: index })), + discount: state.discount, + taxRate: state.taxRate, + reverseCharge: state.reverseCharge, + bankId: state.bankId, + signaturePath: state.signaturePath, + notes: state.notes, + }; +} + +/** Tolerant: any missing or wrongly typed field falls back to `defaults`. */ +export function fromDraftPayload(json: unknown, defaults: InvoiceFormState): InvoiceFormState { + const r = (json && typeof json === "object" ? json : {}) as Record; + const items = Array.isArray(r.items) && r.items.length > 0 ? r.items.map(itemFromJson) : defaults.items; + return { + invoiceDate: str(r.invoiceDate, defaults.invoiceDate), + dueDate: str(r.dueDate, defaults.dueDate), + clientId: nullableNum(r.clientId, defaults.clientId), + clientName: str(r.clientName, defaults.clientName), + clientAddress: str(r.clientAddress, defaults.clientAddress), + clientGstin: str(r.clientGstin, defaults.clientGstin), + poNumber: str(r.poNumber, defaults.poNumber), + placeOfSupply: str(r.placeOfSupply, defaults.placeOfSupply), + saveClient: bool(r.saveClient, defaults.saveClient), + items, + discount: num(r.discount, defaults.discount), + taxRate: num(r.taxRate, defaults.taxRate), + reverseCharge: bool(r.reverseCharge, defaults.reverseCharge), + bankId: nullableNum(r.bankId, defaults.bankId), + signaturePath: + r.signaturePath === null || typeof r.signaturePath === "string" + ? r.signaturePath + : defaults.signaturePath, + notes: str(r.notes, defaults.notes), + }; +} diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss index 231ccd7..a8070de 100644 --- a/src/styles/carbon.scss +++ b/src/styles/carbon.scss @@ -143,6 +143,11 @@ body { gap: 0.5rem; } +// Views stay mounted while hidden so their state survives navigation. +[hidden] { + display: none !important; +} + .voiced-page { padding: 2rem; max-width: 1600px; diff --git a/src/views/InvoiceHistory.tsx b/src/views/InvoiceHistory.tsx index 87f14a1..550df7c 100644 --- a/src/views/InvoiceHistory.tsx +++ b/src/views/InvoiceHistory.tsx @@ -20,7 +20,7 @@ import { formatAmount, formatDate } from "../lib/format"; import { exportInvoicePdf } from "../lib/pdf"; import type { InvoiceSummary, Settings } from "../lib/types"; -export default function InvoiceHistory({ settings }: { settings: Settings }) { +export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -39,9 +39,10 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) { } }; + // The view stays mounted, so refetch every time it is shown again. useEffect(() => { - void load(); - }, []); + if (active) void load(); + }, [active]); const onExport = async (id: number) => { setBusyId(id); diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index cc17f8a..45a9e28 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -1,23 +1,45 @@ -import { useEffect, useMemo, useState } from "react"; +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, Save, TrashCan } from "@carbon/icons-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, @@ -30,6 +52,8 @@ import { amountInWords } from "../lib/numberToWords"; import { gstinFullError } from "../lib/validators"; import { addDays, formatAmount, todayIso } from "../lib/format"; import { buildPdfProps, exportPdfFromProps } 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 "../pdf/InvoiceDocument"; @@ -37,10 +61,43 @@ import type { InvoicePdfProps } from "../pdf/InvoiceDocument"; 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; } -export default function NewInvoice({ settings }: NewInvoiceProps) { +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)); @@ -72,21 +129,43 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [saved, setSaved] = useState(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(null); + const [exporting, setExporting] = useState(false); + const [exportedPath, setExportedPath] = useState(null); + const [exportError, setExportError] = useState(null); + const locked = issued !== null; + + // Drafts. + const [draftId, setDraftId] = useState(null); + const [baseline, setBaseline] = useState(null); + const [savedSnapshot, setSavedSnapshot] = useState(null); + const [savedAt, setSavedAt] = useState(null); + const [draftSaving, setDraftSaving] = useState(false); + const [draftError, setDraftError] = useState(null); + const [drafts, setDrafts] = useState([]); + 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] = await Promise.all([ + 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)); } @@ -135,12 +214,128 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { 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(null); + const savedRef = useRef(null); + const issuedRef = useRef(false); + const chain = useRef>(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 => { + 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: number || "DRAFT", + number: issued?.number ?? (number || "DRAFT"), invoiceDate, dueDate, clientName, @@ -177,35 +372,182 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { if (selected.stateCode) setPlaceOfSupply(selected.stateCode); }; - const resetForNext = async () => { - setClientId(null); - setClientName(""); - setClientAddress(""); - setClientGstin(""); - setPoNumber(""); - setSaveClient(false); - setItems([newItem()]); - setDiscount(0); - setNotes(""); - setInvoiceDate(todayIso()); - setDueDate(addDays(todayIso(), settings.paymentTermsDays)); + 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 => { + if (locked || !unsaved) return true; try { - setNumber(await api.peekNextInvoiceNumber()); - setClients(await api.listClients()); - } catch { - /* ignore */ + 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 props = await buildPdfProps(invoice, settings, bank); + const path = await exportPdfFromProps(props, invoice.number); + 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); - setSaved(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, @@ -223,53 +565,162 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { signaturePath, notes, saveClient, - draftId: null, + draftId: draftIdRef.current, items: items.map((item, index) => ({ ...item, sortOrder: index })), }; - const invoice = await api.issueInvoice(input, {}); - const props = await buildPdfProps(invoice, settings, bank); - const path = await exportPdfFromProps(props, invoice.number); - if (path) setSaved(path); - await resetForNext(); + invoice = await api.issueInvoice(input, {}); } catch (e) { + issuedRef.current = false; setError(String(e)); - } finally { 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 ( -
+ <> +