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 9e17ba3479
commit 3510cc4260
30 changed files with 2119 additions and 204 deletions
+2 -2
View File
@@ -11,12 +11,12 @@ import {
import { Asleep, Light } from "@carbon/icons-react";
import type { ReactNode } from "react";
export type AppView = "new" | "invoices" | "series" | "settings";
export type AppView = "new" | "invoices" | "clients" | "settings";
const NAV: { id: AppView; label: string }[] = [
{ id: "new", label: "New invoice" },
{ id: "invoices", label: "Invoices" },
{ id: "series", label: "Series" },
{ id: "clients", label: "Clients" },
{ id: "settings", label: "Settings" },
];
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState, type RefObject } from "react";
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
import { api } from "../lib/api";
import {
GST_CATEGORY_LABELS,
INDIAN_STATES,
blankClient,
type Client,
type GstCategory,
} from "../lib/types";
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
import { clientGstinStateError } from "../lib/validators";
import { useToast } from "./ToastProvider";
interface ClientModalProps {
open: boolean;
/** The client to edit; null opens a blank one. */
client: Client | null;
onClose: () => void;
onSaved: (client: Client) => void;
/** Focus returns here when the modal closes. */
launcherRef?: RefObject<HTMLElement | null>;
}
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
}
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
const toast = useToast();
const [form, setForm] = useState<Client>(blankClient());
const [split, setSplit] = useState(false);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
useEffect(() => {
if (!open) return;
const prepared = prepareForEdit(client ?? blankClient());
setForm(prepared.form);
setSplit(prepared.splitFromLegacy);
setBusy(false);
setTouched(false);
}, [open, client]);
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
const nameProblem = form.name.trim() ? undefined : "Client name is required";
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
const pinProblem = pincodeError(form.pincode);
const termsProblem =
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
? "Between 0 and 365 days"
: undefined;
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
const submit = async () => {
setTouched(true);
if (blocking) return;
setBusy(true);
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
const toSave: Client = {
...form,
name: form.name.trim(),
gstin: form.gstin.trim().toUpperCase(),
address: structured
? composeAddress({
line1: form.addressLine1,
line2: form.addressLine2,
city: form.city,
stateCode: form.stateCode,
pincode: form.pincode,
})
: form.address,
};
try {
const saved = await api.saveClient(toSave);
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
onSaved(saved);
} catch (e) {
toast.error("Could not save client", String(e));
setBusy(false);
}
};
return (
<Modal
open={open}
modalHeading={client?.id ? "Edit client" : "New client"}
primaryButtonText={busy ? "Saving…" : "Save client"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={onClose}
onRequestSubmit={() => void submit()}
launcherButtonRef={launcherRef}
size="md"
>
<Grid narrow>
{split ? (
<Column sm={4} md={8} lg={16}>
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Address split automatically"
subtitle="This client has a free-text address. Check the lines below before saving."
/>
</Column>
) : null}
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-name"
labelText="Client name"
value={form.name}
onChange={(e) => set("name", e.target.value)}
invalid={touched && Boolean(nameProblem)}
invalidText={nameProblem}
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="cm-category"
labelText="GST category"
value={form.gstCategory}
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
>
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-gstin"
labelText="GSTIN (optional)"
value={form.gstin}
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line1"
labelText="Address line 1"
value={form.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line2"
labelText="Address line 2"
value={form.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
</Column>
<Column sm={4} md={4} lg={7}>
<Select
id="cm-state"
labelText="State (also the default place of supply)"
value={form.stateCode}
onChange={(e) => set("stateCode", e.target.value)}
>
<SelectItem value="" text="Not set" />
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={4}>
<TextInput
id="cm-pin"
labelText="Pincode"
inputMode="numeric"
maxLength={6}
value={form.pincode}
onChange={(e) => set("pincode", e.target.value)}
invalid={Boolean(pinProblem)}
invalidText={pinProblem}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-po"
labelText="Default PO number (optional)"
value={form.poNumber}
onChange={(e) => set("poNumber", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-terms"
labelText="Payment terms (days, blank = default)"
inputMode="numeric"
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
onChange={(e) => {
const raw = e.target.value.trim();
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
}}
invalid={Boolean(termsProblem)}
invalidText={termsProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextArea
id="cm-notes"
labelText="Default notes / payment terms text"
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
rows={3}
value={form.defaultNotes}
onChange={(e) => set("defaultNotes", e.target.value)}
/>
</Column>
</Grid>
</Modal>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
Column,
Grid,
InlineLoading,
Modal,
NumberInput,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TextInput,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider";
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
export default function ItemPresetsPanel({ active }: { active: boolean }) {
const toast = useToast();
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<ItemPreset | null>(null);
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setPresets(await api.listItemPresets());
} catch (e) {
toast.error("Could not load item presets", String(e));
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
launcher.current = trigger;
setTouched(false);
setEditing(preset);
};
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const save = async () => {
if (!editing) return;
setTouched(true);
if (!editing.description.trim()) return;
setBusy(true);
try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null);
await load();
} catch (e) {
toast.error("Could not save preset", String(e));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteItemPreset(deleting.id);
toast.success("Preset deleted", deleting.description);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete preset", String(e));
} finally {
setBusy(false);
}
};
if (loading) return <InlineLoading description="Loading item presets" />;
return (
<div className="voiced-stack">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
<Button
kind="tertiary"
size="sm"
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
>
Add preset
</Button>
</div>
{presets.length === 0 ? (
<div className="voiced-empty">No item presets yet.</div>
) : (
<Table aria-label="Item presets">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Amount</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{presets.map((p) => (
<TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(p);
}}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Modal
open={editing !== null}
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
primaryButtonText={busy ? "Saving…" : "Save preset"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setEditing(null)}
onRequestSubmit={() => void save()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="sm"
>
{editing ? (
<Grid narrow>
<Column sm={4} md={8} lg={16}>
<TextInput
id="ip-desc"
labelText="Description"
value={editing.description}
onChange={(e) => set("description", e.target.value)}
invalid={touched && !editing.description.trim()}
invalidText="Description is required"
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="ip-hsn"
labelText="HSN/SAC (optional)"
value={editing.hsnSac}
onChange={(e) => set("hsnSac", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="ip-mode"
labelText="Type"
value={editing.mode}
onChange={(e) => set("mode", e.target.value as LineMode)}
>
<SelectItem value="fixed" text="Fixed amount" />
<SelectItem value="rate" text="Rate × quantity" />
</Select>
</Column>
{editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}>
<Select
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
) : null}
<Column sm={4} md={4} lg={8}>
<NumberInput
id="ip-rate"
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
min={0}
step={1}
value={paiseToRupees(editing.ratePaise)}
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
/>
</Column>
</Grid>
) : null}
</Modal>
<Modal
danger
open={deleting !== null}
modalHeading="Delete item preset"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void remove()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="xs"
>
<p>
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
</p>
</Modal>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
import { ToastNotification } from "@carbon/react";
import { createToastStore, type ToastStore } from "../lib/toastStore";
export interface ToastApi {
success: (title: string, subtitle?: string) => number;
info: (title: string, subtitle?: string) => number;
warning: (title: string, subtitle?: string) => number;
/** Errors stay until the user closes them. */
error: (title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
function apiFor(store: ToastStore): ToastApi {
return {
success: (t, s) => store.add("success", t, s),
info: (t, s) => store.add("info", t, s),
warning: (t, s) => store.add("warning", t, s),
error: (t, s) => store.add("error", t, s),
dismiss: store.dismiss,
};
}
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
export function ToastProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<ToastStore | null>(null);
if (!storeRef.current) storeRef.current = createToastStore();
const store = storeRef.current;
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
const api = useMemo(() => apiFor(store), [store]);
return (
<ToastContext.Provider value={api}>
{children}
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
{toasts.map((t) => (
<ToastNotification
key={t.id}
kind={t.kind}
lowContrast
title={t.title}
subtitle={t.subtitle}
role={t.kind === "error" ? "alert" : "status"}
onClose={() => {
store.dismiss(t.id);
return false;
}}
onCloseButtonClick={() => store.dismiss(t.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
return api;
}