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
+14 -4
View File
@@ -6,10 +6,20 @@ import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory";
import SeriesSettings from "./views/SeriesSettings";
import Clients from "./views/Clients";
import { ToastProvider, useToast } from "./components/ToastProvider";
import AppSettings from "./views/AppSettings";
export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}
function AppInner() {
const toast = useToast();
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -81,7 +91,7 @@ export default function App() {
try {
await api.saveSettings(next);
} catch (e) {
setError(String(e));
toast.error("Could not save the theme", String(e));
}
};
@@ -98,8 +108,8 @@ export default function App() {
<div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} />
</div>
<div hidden={view !== "series"}>
<SeriesSettings active={view === "series"} />
<div hidden={view !== "clients"}>
<Clients active={view === "clients"} />
</div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell>
+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;
}
+5
View File
@@ -7,6 +7,7 @@ import type {
InvoiceInput,
InvoiceSeries,
InvoiceSummary,
ItemPreset,
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
@@ -36,6 +37,10 @@ export const api = {
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
describe("composeAddress", () => {
it("joins lines and the city/state/pincode line", () => {
expect(
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
});
it("skips empty parts", () => {
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
});
});
describe("splitLegacyAddress", () => {
it("puts the first line in line1 and the rest in line2", () => {
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
line1: "Old style",
line2: "free text, Bangalore",
});
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
});
});
describe("pincodeError", () => {
it("accepts empty and 6 digits only", () => {
expect(pincodeError("")).toBeUndefined();
expect(pincodeError("560001")).toBeUndefined();
expect(pincodeError("56001")).toBeDefined();
expect(pincodeError("56000A")).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { INDIAN_STATES } from "./types";
export interface AddressParts {
line1: string;
line2: string;
city: string;
stateCode: string;
pincode: string;
}
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
/**
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
*/
export function composeAddress(parts: AddressParts): string {
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
const pin = parts.pincode.trim();
const last = place && pin ? `${place} - ${pin}` : place || pin;
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
}
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
const lines = address
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
}
export function isPincode(value: string): boolean {
return /^[0-9]{6}$/.test(value.trim());
}
export function pincodeError(value: string): string | undefined {
if (!value.trim()) return undefined;
return isPincode(value) ? undefined : "Pincode is 6 digits";
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
import { blankPreset } from "./types";
describe("preset money helpers", () => {
it("rounds rupees to whole paise", () => {
expect(rupeesToPaise(1500.5)).toBe(150050);
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
expect(rupeesToPaise(19.99)).toBe(1999);
expect(rupeesToPaise(Number.NaN)).toBe(0);
expect(paiseToRupees(150050)).toBe(1500.5);
});
});
describe("itemFromPreset", () => {
it("fills a fixed line", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
});
it("fills a rate line and falls back to the default HSN/SAC", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { InvoiceItem, ItemPreset } from "./types";
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
export const paiseToRupees = (paise: number): number => paise / 100;
/**
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
* preset amount is converted only here.
*/
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
const rupees = paiseToRupees(preset.ratePaise);
return {
description: preset.description,
mode: preset.mode,
rate: preset.mode === "rate" ? rupees : 0,
unit: preset.unit,
quantity: 1,
amount: preset.mode === "fixed" ? rupees : 0,
sortOrder: 0,
hsnSac: preset.hsnSac || defaults.hsnSac,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => {
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
expect(parseSettingsTab("nope")).toBe(0);
expect(parseSettingsTab(null)).toBe(0);
});
it("does not throw when storage is unavailable", () => {
// The node test environment has no localStorage at all.
expect(loadSettingsTab()).toBe(0);
expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
const KEY = "voiced.settingsTab";
export const SETTINGS_TABS = [
{ id: "business", label: "Business" },
{ id: "branding", label: "Branding" },
{ id: "bank", label: "Bank & payments" },
{ id: "defaults", label: "Invoice defaults" },
{ id: "numbering", label: "Numbering" },
{ id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" },
] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
/** Index of a stored tab id; 0 (Business) for anything unknown. */
export function parseSettingsTab(value: string | null | undefined): number {
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
return index < 0 ? 0 : index;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number {
try {
return parseSettingsTab(localStorage.getItem(KEY));
} catch {
return 0;
}
}
export function saveSettingsTab(index: number): void {
try {
const tab = SETTINGS_TABS[index];
if (tab) localStorage.setItem(KEY, tab.id);
} catch {
// Not remembering is harmless.
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
function fakeTimers() {
let next = 1;
const pending = new Map<number, { fn: () => void; ms: number }>();
return {
setTimer: (fn: () => void, ms: number) => {
const id = next++;
pending.set(id, { fn, ms });
return id;
},
clearTimer: (id: unknown) => void pending.delete(id as number),
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
pending,
};
}
describe("toast store", () => {
it("auto-dismisses non-error toasts after the delay", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "Saved");
store.add("info", "FYI");
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
timers.fireAll();
expect(store.getSnapshot()).toHaveLength(0);
});
it("keeps errors until they are closed", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("error", "Failed", "details");
expect(timers.pending.size).toBe(0);
timers.fireAll();
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("dismiss cancels the timer and is idempotent", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("success", "x");
store.dismiss(id);
expect(timers.pending.size).toBe(0);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("notifies subscribers with a new snapshot reference per change", () => {
const store = createToastStore(fakeTimers());
let calls = 0;
const off = store.subscribe(() => calls++);
const before = store.getSnapshot();
store.add("info", "a");
expect(calls).toBe(1);
expect(store.getSnapshot()).not.toBe(before);
const same = store.getSnapshot();
store.dismiss(999);
expect(calls).toBe(1);
expect(store.getSnapshot()).toBe(same);
off();
store.add("info", "b");
expect(calls).toBe(1);
});
it("caps the stack and drops the oldest first, cancelling its timer", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
const titles = store.getSnapshot().map((t) => t.title);
expect(titles).toHaveLength(MAX_TOASTS);
expect(titles[0]).toBe("t2");
expect(timers.pending.size).toBe(MAX_TOASTS);
});
it("clear empties the stack and cancels timers", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "a");
store.add("error", "b");
store.clear();
expect(store.getSnapshot()).toHaveLength(0);
expect(timers.pending.size).toBe(0);
});
});
+83
View File
@@ -0,0 +1,83 @@
export type ToastKind = "success" | "info" | "warning" | "error";
export interface Toast {
id: number;
kind: ToastKind;
title: string;
subtitle?: string;
}
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
export const AUTO_DISMISS_MS = 5000;
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
export const MAX_TOASTS = 5;
type TimerId = unknown;
export interface ToastStoreOptions {
setTimer?: (fn: () => void, ms: number) => TimerId;
clearTimer?: (id: TimerId) => void;
}
export interface ToastStore {
add: (kind: ToastKind, title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
clear: () => void;
subscribe: (listener: () => void) => () => void;
getSnapshot: () => readonly Toast[];
}
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
let toasts: readonly Toast[] = [];
let nextId = 1;
const timers = new Map<number, TimerId>();
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
const drop = (id: number) => {
const timer = timers.get(id);
if (timer !== undefined) {
clearTimer(timer);
timers.delete(id);
}
};
const dismiss = (id: number) => {
drop(id);
if (!toasts.some((t) => t.id === id)) return;
toasts = toasts.filter((t) => t.id !== id);
emit();
};
return {
add(kind, title, subtitle) {
const id = nextId++;
toasts = [...toasts, { id, kind, title, subtitle }];
while (toasts.length > MAX_TOASTS) {
drop(toasts[0].id);
toasts = toasts.slice(1);
}
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
emit();
return id;
},
dismiss,
clear() {
timers.forEach((t) => clearTimer(t));
timers.clear();
toasts = [];
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => toasts,
};
}
+64
View File
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
nextInvoiceNumber: string;
}
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
/** Informational only; the tax heads still derive from supplier state and place of supply. */
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
registered_regular: "Registered (regular)",
unregistered: "Unregistered",
composition: "Composition",
sez: "SEZ",
overseas: "Overseas",
};
export interface Client {
id: number | null;
name: string;
/** The composed multi-line address; what invoices store and print. */
address: string;
gstin: string;
/** Doubles as the address state and the default place of supply. */
stateCode: string;
poNumber: string;
createdAt: string;
addressLine1: string;
addressLine2: string;
city: string;
pincode: string;
gstCategory: GstCategory;
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
defaultNotes: string;
/** Null means the settings default. */
paymentTermsDays: number | null;
/** Read-only: how many invoices reference this client. */
invoiceCount?: number;
}
export const blankClient = (): Client => ({
id: null,
name: "",
address: "",
gstin: "",
stateCode: "",
poNumber: "",
createdAt: "",
addressLine1: "",
addressLine2: "",
city: "",
pincode: "",
gstCategory: "unregistered",
defaultNotes: "",
paymentTermsDays: null,
});
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
export interface ItemPreset {
id: number | null;
description: string;
hsnSac: string;
mode: LineMode;
unit: LineUnit;
ratePaise: number;
sortOrder: number;
createdAt: string;
}
export const blankPreset = (): ItemPreset => ({
id: null,
description: "",
hsnSac: "",
mode: "fixed",
unit: "unit",
ratePaise: 0,
sortOrder: 0,
createdAt: "",
});
export interface InvoiceItem {
id?: number | null;
description: string;
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => {
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
});
});
describe("clientGstinStateError", () => {
it("allows empty and NA", () => {
expect(clientGstinStateError("", "29")).toBeUndefined();
expect(clientGstinStateError("NA", "29")).toBeUndefined();
});
it("checks shape and checksum", () => {
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
});
it("checks the state prefix only when a state is chosen", () => {
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
});
});
+15
View File
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
/**
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
* are given. Empty (or "NA") is valid; a client may be unregistered.
*/
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
const g = norm(gstin);
if (!g || g === "NA") return undefined;
const base = gstinFullError(g);
if (base) return base;
if (stateCode && g.slice(0, 2) !== stateCode) {
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
}
return undefined;
}
+17
View File
@@ -148,6 +148,23 @@ body {
display: none !important;
}
.voiced-toasts {
position: fixed;
top: 3.5rem; /* below the 3rem Carbon header */
right: 1rem;
z-index: 9100;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.voiced-toasts .cds--toast-notification {
pointer-events: auto;
margin: 0;
}
.voiced-page {
padding: 2rem;
max-width: 1600px;
+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"}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
DataTable,
InlineLoading,
Modal,
Tab,
TabList,
TabPanel,
TabPanels,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TableToolbar,
TableToolbarContent,
TableToolbarSearch,
Tabs,
Tag,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { GST_CATEGORY_LABELS, INDIAN_STATES, type Client } from "../lib/types";
import ClientModal from "../components/ClientModal";
import ItemPresetsPanel from "../components/ItemPresetsPanel";
import { useToast } from "../components/ToastProvider";
const HEADERS = [
{ key: "name", header: "Name" },
{ key: "gstin", header: "GSTIN" },
{ key: "category", header: "GST category" },
{ key: "place", header: "City / State" },
{ key: "invoices", header: "Invoices" },
{ key: "actions", header: "" },
];
const stateName = (code: string) => INDIAN_STATES.find((s) => s.code === code)?.name ?? "";
export default function Clients({ active }: { active: boolean }) {
const toast = useToast();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Client | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleting, setDeleting] = useState<Client | null>(null);
const [busy, setBusy] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setClients(await api.listClients());
} catch (e) {
toast.error("Could not load clients", String(e));
} finally {
setLoading(false);
}
}, [toast]);
// The view stays mounted, so refetch whenever it is shown again.
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (client: Client | null, trigger: HTMLElement) => {
launcher.current = trigger;
setEditing(client);
setModalOpen(true);
};
const confirmDelete = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteClient(deleting.id);
toast.success("Client deleted", deleting.name);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete client", String(e));
} finally {
setBusy(false);
}
};
const byId = new Map(clients.map((c) => [String(c.id), c]));
const rows = clients.map((c) => ({
id: String(c.id),
name: c.name,
gstin: c.gstin || "—",
category: GST_CATEGORY_LABELS[c.gstCategory] ?? c.gstCategory,
place: [c.city, stateName(c.stateCode)].filter(Boolean).join(", ") || "—",
invoices: c.invoiceCount ?? 0,
actions: "",
}));
const clientsTable = loading ? (
<InlineLoading description="Loading clients" />
) : (
<DataTable rows={rows} headers={HEADERS} isSortable>
{({ rows: tableRows, headers, getHeaderProps, getRowProps, getTableProps, onInputChange }) => (
<TableContainer>
<TableToolbar>
<TableToolbarContent>
<TableToolbarSearch
persistent
placeholder="Search clients"
labelText="Search clients"
onChange={(e) => onInputChange(e as React.ChangeEvent<HTMLInputElement>)}
/>
<Button
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(null, e.currentTarget)}
>
Add client
</Button>
</TableToolbarContent>
</TableToolbar>
<Table {...getTableProps()} aria-label="Clients">
<TableHead>
<TableRow>
{headers.map((header) => {
const { key, ...headerProps } = getHeaderProps({ header });
return header.key === "actions" ? (
<TableHeader key={key} aria-label="Actions" />
) : (
<TableHeader key={key} {...headerProps}>
{header.header}
</TableHeader>
);
})}
</TableRow>
</TableHead>
<TableBody>
{tableRows.map((row) => {
const client = byId.get(row.id);
const { key, ...rowProps } = getRowProps({ row });
return (
<TableRow key={key} {...rowProps}>
{row.cells.map((cell) => {
if (cell.info.header === "actions") {
return (
<TableCell key={cell.id}>
{client ? (
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(client, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(client);
}}
>
Delete
</Button>
</div>
) : null}
</TableCell>
);
}
if (cell.info.header === "category" && client) {
return (
<TableCell key={cell.id}>
<Tag type={client.gstCategory === "unregistered" ? "gray" : "blue"} size="sm">
{cell.value}
</Tag>
</TableCell>
);
}
return <TableCell key={cell.id}>{cell.value}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
{clients.length === 0 ? <div className="voiced-empty">No clients yet. Add one to reuse it on invoices.</div> : null}
</TableContainer>
)}
</DataTable>
);
const count = deleting?.invoiceCount ?? 0;
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Clients</h1>
<p className="voiced-page__subtitle">Saved clients and item presets for faster invoicing.</p>
<Tabs>
<TabList aria-label="Clients and presets" contained>
<Tab>Clients</Tab>
<Tab>Item presets</Tab>
</TabList>
<TabPanels>
<TabPanel>{clientsTable}</TabPanel>
<TabPanel>
<ItemPresetsPanel active={active} />
</TabPanel>
</TabPanels>
</Tabs>
<ClientModal
open={modalOpen}
client={editing}
launcherRef={launcher}
onClose={() => setModalOpen(false)}
onSaved={() => {
setModalOpen(false);
void load();
}}
/>
<Modal
danger
open={deleting !== null}
modalHeading="Delete client"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void confirmDelete()}
launcherButtonRef={launcher}
size="xs"
>
<p>
Delete “{deleting?.name}”?{" "}
{count > 0
? `${count} invoice${count === 1 ? "" : "s"} refer${count === 1 ? "s" : ""} to this client. They keep their own copy of the client's name, address and GSTIN, so they are not changed.`
: "No invoices refer to this client."}
</p>
</Modal>
</div>
);
}
+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 ? (
+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(() => {});
}}
/>
</>
);
}
+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