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
+133 -101
View File
@@ -3,7 +3,6 @@ import {
Button,
Column,
Grid,
InlineNotification,
Link,
NumberInput,
RadioButton,
@@ -19,6 +18,11 @@ import {
TableRow,
TextArea,
TextInput,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Tile,
Toggle,
} from "@carbon/react";
@@ -37,6 +41,9 @@ import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
import SeriesSettings from "./SeriesSettings";
const blankBank = (): BankAccount => ({
id: null,
@@ -61,14 +68,20 @@ export default function AppSettings({
const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const toast = useToast();
// One form state for every tab and one Save button, so edits survive switching tabs.
const [tab, setTab] = useState(loadSettingsTab);
const selectTab = (index: number) => {
setTab(index);
saveSettingsTab(index);
};
const loadBanks = async () => {
try {
setBanks(await api.listBanks());
} catch (e) {
setError(String(e));
toast.error("Could not load bank accounts", String(e));
}
};
@@ -92,18 +105,17 @@ export default function AppSettings({
const saveAll = async () => {
if (gstinProblem) {
setError(gstinProblem);
selectTab(0);
toast.error("Settings not saved", gstinProblem);
return;
}
setBusy(true);
setError(null);
setNotice(null);
try {
await api.saveSettings(form);
setNotice("Settings saved.");
toast.success("Settings saved");
onSaved();
} catch (e) {
setError(String(e));
toast.error("Could not save settings", String(e));
} finally {
setBusy(false);
}
@@ -112,7 +124,6 @@ export default function AppSettings({
const saveBank = async () => {
if (!editing) return;
setBusy(true);
setError(null);
try {
const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) {
@@ -120,8 +131,9 @@ export default function AppSettings({
}
setEditing(null);
await loadBanks();
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
} catch (e) {
setError(String(e));
toast.error("Could not save bank account", String(e));
} finally {
setBusy(false);
}
@@ -139,30 +151,29 @@ export default function AppSettings({
if (form.defaultBankId === bank.id) set("defaultBankId", null);
await loadBanks();
} catch (e) {
setError(String(e));
toast.error("Could not delete bank account", String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Saved"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p>
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
<TabList aria-label="Settings sections" contained scrollIntoView>
<Tab>Business</Tab>
<Tab>Branding</Tab>
<Tab>Bank &amp; payments</Tab>
<Tab>Invoice defaults</Tab>
<Tab>Numbering</Tab>
<Tab>Templates &amp; export</Tab>
<Tab>Fonts</Tab>
<Tab>Data</Tab>
</TabList>
<TabPanels>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
@@ -277,9 +288,9 @@ export default function AppSettings({
</Column>
</Grid>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Branding</h3>
<Tile>
<LogoBranding
idPrefix="s"
@@ -289,78 +300,8 @@ export default function AppSettings({
/>
</Tile>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Fonts</h3>
<Tile>
<FontOverrideTable />
</Tile>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Bank accounts</h3>
@@ -487,6 +428,97 @@ export default function AppSettings({
</div>
) : null}
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>
The template, page size and margins are chosen next to the preview on the New invoice screen. The
default page setup is saved from there.
</p>
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Tile>
<FontOverrideTable />
</Tile>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>Backup and export arrive in the next step.</p>
</section>
</TabPanel>
</TabPanels>
</Tabs>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"}