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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user