- 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.
84 lines
2.3 KiB
TypeScript
84 lines
2.3 KiB
TypeScript
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,
|
|
};
|
|
}
|