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.
This commit is contained in:
2026-10-04 04:21:47 +05:30
parent 5597fb791d
commit fbed18a0bd
8 changed files with 859 additions and 62 deletions
+72
View File
@@ -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: "" });
});
});
+92
View File
@@ -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<string, unknown>;
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<string, unknown> {
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<string, unknown>;
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),
};
}