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 a81aca0ca0
commit 708fa7b610
30 changed files with 2119 additions and 204 deletions
+19 -27
View File
@@ -4,7 +4,6 @@ import {
Column,
Grid,
InlineLoading,
InlineNotification,
NumberInput,
Table,
TableBody,
@@ -19,8 +18,17 @@ import {
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types";
import { useToast } from "../components/ToastProvider";
export default function SeriesSettings({ active: visible }: { active: boolean }) {
export default function SeriesSettings({
active: visible,
embedded = false,
}: {
active: boolean;
/** Rendered inside Settings: no page wrapper or title. */
embedded?: boolean;
}) {
const toast = useToast();
const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true);
@@ -28,8 +36,6 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const seeded = useRef(false);
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
setPadding(current.padding);
seeded.current = true;
}
setError(null);
} catch (e) {
setError(String(e));
toast.error("Could not load series", String(e));
} finally {
setLoading(false);
}
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
);
if (!ok) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const created = await api.startNewSeries(prefix, padding);
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
await load();
} catch (e) {
setError(String(e));
toast.error("Could not start a new series", String(e));
} finally {
setBusy(false);
}
};
const pageClass = embedded ? undefined : "voiced-page";
if (loading) {
return (
<div className="voiced-page">
<div className={pageClass}>
<InlineLoading description="Loading series" />
</div>
);
}
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoice series</h1>
<p className="voiced-page__subtitle">
<div className={pageClass}>
{embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
<p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Done"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<Grid narrow style={{ marginBottom: "2rem" }}>
<Column sm={4} md={4} lg={6}>
<TextInput