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
+79 -18
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
import {
ActionableNotification,
Button,
ComboBox,
Column,
Dropdown,
Grid,
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type {
BankAccount,
Client,
ItemPreset,
DraftSummary,
Invoice,
InvoiceInput,
@@ -68,6 +70,9 @@ import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo";
import ClientModal from "../components/ClientModal";
import { useToast } from "../components/ToastProvider";
import { itemFromPreset } from "../lib/presets";
// pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview"));
@@ -90,6 +95,7 @@ const formatStamp = (iso: string) => {
};
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
const toast = useToast();
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const blankState = (): InvoiceFormState => {
const t = todayIso();
@@ -118,6 +124,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
const [number, setNumber] = useState("");
const [clients, setClients] = useState<Client[]>([]);
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [clientModalOpen, setClientModalOpen] = useState(false);
const newClientButton = useRef<HTMLButtonElement | null>(null);
const [clientId, setClientId] = useState<number | null>(null);
const [clientName, setClientName] = useState("");
const [clientAddress, setClientAddress] = useState("");
@@ -188,7 +197,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setDrafts(draftList);
setNoticeOpen(draftList.length > 0);
} catch (e) {
if (active) setError(String(e));
if (active) toast.error("Could not load invoice data", String(e));
}
})();
return () => {
@@ -196,6 +205,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
};
}, []);
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
useEffect(() => {
if (!active) return;
api.listClients().then(setClients).catch(() => {});
api.listItemPresets().then(setPresets).catch(() => {});
}, [active]);
useEffect(() => {
let active = true;
if (!settings.logoPath) {
@@ -333,6 +349,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
return next;
}, []);
// A failed autosave is reported once per failure streak (the message clears on the next success).
useEffect(() => {
if (draftError) toast.warning("Draft not saved", draftError);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftError]);
useEffect(() => {
if (!unsaved) return;
const timer = setTimeout(() => {
@@ -390,6 +412,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setClientGstin(selected.gstin);
setPoNumber(selected.poNumber);
if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
// Client defaults fill gaps only: typed notes are never overwritten.
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
};
const addPreset = (preset: ItemPreset) => {
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
};
const applyState = (s: InvoiceFormState) => {
@@ -527,7 +558,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
void refreshNumber();
void refreshDrafts();
} catch (e) {
setError(String(e));
toast.error("Could not open the draft", String(e));
}
};
@@ -535,7 +566,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
try {
await api.deleteDraft(id);
} catch (e) {
setError(String(e));
toast.error("Could not delete the draft", String(e));
}
await refreshDrafts();
};
@@ -596,7 +627,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) {
issuedRef.current = false;
setError(String(e));
toast.error("Could not issue the invoice", String(e));
setSaving(false);
return;
}
@@ -681,20 +712,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<InlineNotification
kind="error"
lowContrast
title="Could not save invoice"
title="Fix this before issuing"
subtitle={error}
onCloseButtonClick={() => setError(null)}
/>
) : null}
{draftError ? (
<InlineNotification
kind="warning"
lowContrast
title="Draft not saved"
subtitle={draftError}
onCloseButtonClick={() => setDraftError(null)}
/>
) : null}
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
<ActionableNotification
inline
@@ -766,16 +788,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</Column>
<Column sm={4} md={8} lg={8}>
<Dropdown
<ComboBox
id="savedClient"
disabled={locked}
titleText="Saved client (optional)"
label="Select a saved client to autofill"
placeholder="Type to search saved clients"
items={clients}
itemToString={(item) => (item ? item.name : "")}
itemToString={(item: Client | null) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)}
shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
const q = (inputValue ?? "").trim().toLowerCase();
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
}}
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
/>
<Button
ref={newClientButton}
kind="ghost"
size="sm"
renderIcon={Add}
disabled={locked}
onClick={() => setClientModalOpen(true)}
>
Create client
</Button>
</Column>
<Column sm={4} md={8} lg={8}>
<TextInput
@@ -845,6 +881,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
{presets.length > 0 ? (
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
<Dropdown
id="addFromPreset"
disabled={locked}
titleText="Add from preset"
label="Choose a saved item"
items={presets}
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
selectedItem={null}
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
/>
</div>
) : null}
{items.map((item, index) => (
<div
key={index}
@@ -1134,6 +1184,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</TableBody>
</Table>
</Modal>
<ClientModal
open={clientModalOpen}
client={null}
launcherRef={newClientButton}
onClose={() => setClientModalOpen(false)}
onSaved={(saved) => {
setClientModalOpen(false);
applyClient(saved);
api.listClients().then(setClients).catch(() => {});
}}
/>
</>
);
}