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
+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);
});
});