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:
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user