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