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 5d377ce85e
commit db2075c409
30 changed files with 2119 additions and 204 deletions
+9 -19
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Modal,
OverflowMenu,
OverflowMenuItem,
@@ -24,17 +23,17 @@ import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceSummary, Settings } from "../lib/types";
import { useToast } from "../components/ToastProvider";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({});
const [notice, setNotice] = useState<string | null>(null);
const exp = useInvoiceExport({
settings,
bank: null,
@@ -44,9 +43,8 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const load = async () => {
try {
setRows(await api.listInvoices());
setError(null);
} catch (e) {
setError(String(e));
toast.error("Could not load invoices", String(e));
} finally {
setLoading(false);
}
@@ -78,14 +76,12 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id);
setError(null);
setNotice(null);
try {
// Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source);
} catch (e) {
setError(String(e));
toast.error("Export failed", String(e));
} finally {
setBusyId(null);
}
@@ -93,19 +89,17 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const onArchive = async (id: number) => {
setBusyId(id);
setError(null);
setNotice(null);
try {
const invoice = await api.getInvoice(id);
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true }));
setNotice(`Invoice ${invoice.number} is archived.`);
toast.success(`Invoice ${invoice.number} is archived`);
} else {
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
toast.error("Could not archive the PDF", `Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
}
} catch (e) {
setError(String(e));
toast.error("Could not archive the PDF", String(e));
} finally {
setBusyId(null);
}
@@ -115,12 +109,13 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
if (!cancelTarget) return;
try {
await api.cancelInvoice(cancelTarget.id, reason);
toast.success(`Invoice ${cancelTarget.number} cancelled`);
setCancelTarget(null);
setReason("");
await load();
} catch (e) {
setCancelTarget(null);
setError(String(e));
toast.error("Could not cancel the invoice", String(e));
}
};
@@ -129,11 +124,6 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
<ExportFeedback exp={exp} />
{loading ? (