Add toasts, Settings tabs, Clients page and item presets (Phase E1)

- Global Carbon toast stack replaces scattered inline notices; errors persist until closed.
- Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings.
- New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot.
- Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular.
- New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control.
- Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
2026-10-04 17:29:46 +05:30
parent 9e17ba3479
commit 3510cc4260
30 changed files with 2119 additions and 204 deletions
+5
View File
@@ -7,6 +7,7 @@ import type {
InvoiceInput,
InvoiceSeries,
InvoiceSummary,
ItemPreset,
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
@@ -36,6 +37,10 @@ export const api = {
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
describe("composeAddress", () => {
it("joins lines and the city/state/pincode line", () => {
expect(
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
});
it("skips empty parts", () => {
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
});
});
describe("splitLegacyAddress", () => {
it("puts the first line in line1 and the rest in line2", () => {
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
line1: "Old style",
line2: "free text, Bangalore",
});
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
});
});
describe("pincodeError", () => {
it("accepts empty and 6 digits only", () => {
expect(pincodeError("")).toBeUndefined();
expect(pincodeError("560001")).toBeUndefined();
expect(pincodeError("56001")).toBeDefined();
expect(pincodeError("56000A")).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { INDIAN_STATES } from "./types";
export interface AddressParts {
line1: string;
line2: string;
city: string;
stateCode: string;
pincode: string;
}
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
/**
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
*/
export function composeAddress(parts: AddressParts): string {
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
const pin = parts.pincode.trim();
const last = place && pin ? `${place} - ${pin}` : place || pin;
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
}
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
const lines = address
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
}
export function isPincode(value: string): boolean {
return /^[0-9]{6}$/.test(value.trim());
}
export function pincodeError(value: string): string | undefined {
if (!value.trim()) return undefined;
return isPincode(value) ? undefined : "Pincode is 6 digits";
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
import { blankPreset } from "./types";
describe("preset money helpers", () => {
it("rounds rupees to whole paise", () => {
expect(rupeesToPaise(1500.5)).toBe(150050);
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
expect(rupeesToPaise(19.99)).toBe(1999);
expect(rupeesToPaise(Number.NaN)).toBe(0);
expect(paiseToRupees(150050)).toBe(1500.5);
});
});
describe("itemFromPreset", () => {
it("fills a fixed line", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
});
it("fills a rate line and falls back to the default HSN/SAC", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { InvoiceItem, ItemPreset } from "./types";
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
export const paiseToRupees = (paise: number): number => paise / 100;
/**
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
* preset amount is converted only here.
*/
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
const rupees = paiseToRupees(preset.ratePaise);
return {
description: preset.description,
mode: preset.mode,
rate: preset.mode === "rate" ? rupees : 0,
unit: preset.unit,
quantity: 1,
amount: preset.mode === "fixed" ? rupees : 0,
sortOrder: 0,
hsnSac: preset.hsnSac || defaults.hsnSac,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => {
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
expect(parseSettingsTab("nope")).toBe(0);
expect(parseSettingsTab(null)).toBe(0);
});
it("does not throw when storage is unavailable", () => {
// The node test environment has no localStorage at all.
expect(loadSettingsTab()).toBe(0);
expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
const KEY = "voiced.settingsTab";
export const SETTINGS_TABS = [
{ id: "business", label: "Business" },
{ id: "branding", label: "Branding" },
{ id: "bank", label: "Bank & payments" },
{ id: "defaults", label: "Invoice defaults" },
{ id: "numbering", label: "Numbering" },
{ id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" },
] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
/** Index of a stored tab id; 0 (Business) for anything unknown. */
export function parseSettingsTab(value: string | null | undefined): number {
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
return index < 0 ? 0 : index;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number {
try {
return parseSettingsTab(localStorage.getItem(KEY));
} catch {
return 0;
}
}
export function saveSettingsTab(index: number): void {
try {
const tab = SETTINGS_TABS[index];
if (tab) localStorage.setItem(KEY, tab.id);
} catch {
// Not remembering is harmless.
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
function fakeTimers() {
let next = 1;
const pending = new Map<number, { fn: () => void; ms: number }>();
return {
setTimer: (fn: () => void, ms: number) => {
const id = next++;
pending.set(id, { fn, ms });
return id;
},
clearTimer: (id: unknown) => void pending.delete(id as number),
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
pending,
};
}
describe("toast store", () => {
it("auto-dismisses non-error toasts after the delay", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "Saved");
store.add("info", "FYI");
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
timers.fireAll();
expect(store.getSnapshot()).toHaveLength(0);
});
it("keeps errors until they are closed", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("error", "Failed", "details");
expect(timers.pending.size).toBe(0);
timers.fireAll();
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("dismiss cancels the timer and is idempotent", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("success", "x");
store.dismiss(id);
expect(timers.pending.size).toBe(0);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("notifies subscribers with a new snapshot reference per change", () => {
const store = createToastStore(fakeTimers());
let calls = 0;
const off = store.subscribe(() => calls++);
const before = store.getSnapshot();
store.add("info", "a");
expect(calls).toBe(1);
expect(store.getSnapshot()).not.toBe(before);
const same = store.getSnapshot();
store.dismiss(999);
expect(calls).toBe(1);
expect(store.getSnapshot()).toBe(same);
off();
store.add("info", "b");
expect(calls).toBe(1);
});
it("caps the stack and drops the oldest first, cancelling its timer", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
const titles = store.getSnapshot().map((t) => t.title);
expect(titles).toHaveLength(MAX_TOASTS);
expect(titles[0]).toBe("t2");
expect(timers.pending.size).toBe(MAX_TOASTS);
});
it("clear empties the stack and cancels timers", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "a");
store.add("error", "b");
store.clear();
expect(store.getSnapshot()).toHaveLength(0);
expect(timers.pending.size).toBe(0);
});
});
+83
View File
@@ -0,0 +1,83 @@
export type ToastKind = "success" | "info" | "warning" | "error";
export interface Toast {
id: number;
kind: ToastKind;
title: string;
subtitle?: string;
}
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
export const AUTO_DISMISS_MS = 5000;
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
export const MAX_TOASTS = 5;
type TimerId = unknown;
export interface ToastStoreOptions {
setTimer?: (fn: () => void, ms: number) => TimerId;
clearTimer?: (id: TimerId) => void;
}
export interface ToastStore {
add: (kind: ToastKind, title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
clear: () => void;
subscribe: (listener: () => void) => () => void;
getSnapshot: () => readonly Toast[];
}
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
let toasts: readonly Toast[] = [];
let nextId = 1;
const timers = new Map<number, TimerId>();
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
const drop = (id: number) => {
const timer = timers.get(id);
if (timer !== undefined) {
clearTimer(timer);
timers.delete(id);
}
};
const dismiss = (id: number) => {
drop(id);
if (!toasts.some((t) => t.id === id)) return;
toasts = toasts.filter((t) => t.id !== id);
emit();
};
return {
add(kind, title, subtitle) {
const id = nextId++;
toasts = [...toasts, { id, kind, title, subtitle }];
while (toasts.length > MAX_TOASTS) {
drop(toasts[0].id);
toasts = toasts.slice(1);
}
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
emit();
return id;
},
dismiss,
clear() {
timers.forEach((t) => clearTimer(t));
timers.clear();
toasts = [];
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => toasts,
};
}
+64
View File
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
nextInvoiceNumber: string;
}
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
/** Informational only; the tax heads still derive from supplier state and place of supply. */
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
registered_regular: "Registered (regular)",
unregistered: "Unregistered",
composition: "Composition",
sez: "SEZ",
overseas: "Overseas",
};
export interface Client {
id: number | null;
name: string;
/** The composed multi-line address; what invoices store and print. */
address: string;
gstin: string;
/** Doubles as the address state and the default place of supply. */
stateCode: string;
poNumber: string;
createdAt: string;
addressLine1: string;
addressLine2: string;
city: string;
pincode: string;
gstCategory: GstCategory;
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
defaultNotes: string;
/** Null means the settings default. */
paymentTermsDays: number | null;
/** Read-only: how many invoices reference this client. */
invoiceCount?: number;
}
export const blankClient = (): Client => ({
id: null,
name: "",
address: "",
gstin: "",
stateCode: "",
poNumber: "",
createdAt: "",
addressLine1: "",
addressLine2: "",
city: "",
pincode: "",
gstCategory: "unregistered",
defaultNotes: "",
paymentTermsDays: null,
});
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
export interface ItemPreset {
id: number | null;
description: string;
hsnSac: string;
mode: LineMode;
unit: LineUnit;
ratePaise: number;
sortOrder: number;
createdAt: string;
}
export const blankPreset = (): ItemPreset => ({
id: null,
description: "",
hsnSac: "",
mode: "fixed",
unit: "unit",
ratePaise: 0,
sortOrder: 0,
createdAt: "",
});
export interface InvoiceItem {
id?: number | null;
description: string;
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => {
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
});
});
describe("clientGstinStateError", () => {
it("allows empty and NA", () => {
expect(clientGstinStateError("", "29")).toBeUndefined();
expect(clientGstinStateError("NA", "29")).toBeUndefined();
});
it("checks shape and checksum", () => {
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
});
it("checks the state prefix only when a state is chosen", () => {
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
});
});
+15
View File
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
/**
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
* are given. Empty (or "NA") is valid; a client may be unregistered.
*/
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
const g = norm(gstin);
if (!g || g === "NA") return undefined;
const base = gstinFullError(g);
if (base) return base;
if (stateCode && g.slice(0, 2) !== stateCode) {
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
}
return undefined;
}