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