- 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.
157 lines
4.8 KiB
TypeScript
157 lines
4.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Button,
|
|
Column,
|
|
Grid,
|
|
InlineLoading,
|
|
NumberInput,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
Tag,
|
|
TextInput,
|
|
} from "@carbon/react";
|
|
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,
|
|
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);
|
|
|
|
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
|
|
const [padding, setPadding] = useState(3);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const seeded = useRef(false);
|
|
|
|
const load = async () => {
|
|
try {
|
|
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
|
|
setActive(current);
|
|
setAll(list);
|
|
// Only seed the form once, so a refetch on re-entry does not overwrite what was typed.
|
|
if (current && !seeded.current) {
|
|
setPrefix(current.prefix);
|
|
setPadding(current.padding);
|
|
seeded.current = true;
|
|
}
|
|
} catch (e) {
|
|
toast.error("Could not load series", String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// The view stays mounted, so refetch every time it is shown again.
|
|
useEffect(() => {
|
|
if (visible) void load();
|
|
}, [visible]);
|
|
|
|
const startNew = async () => {
|
|
const ok = await confirm(
|
|
`Start a new series from ${prefix}${String(1).padStart(padding, "0")}? ` +
|
|
"The current series will be retired and the counter resets to 1.",
|
|
{ title: "Start new series", kind: "warning" },
|
|
);
|
|
if (!ok) return;
|
|
setBusy(true);
|
|
try {
|
|
const created = await api.startNewSeries(prefix, padding);
|
|
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
|
|
await load();
|
|
} catch (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={pageClass}>
|
|
<InlineLoading description="Loading series" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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>
|
|
|
|
<Grid narrow style={{ marginBottom: "2rem" }}>
|
|
<Column sm={4} md={4} lg={6}>
|
|
<TextInput
|
|
id="seriesPrefix"
|
|
labelText="New series prefix"
|
|
helperText={`Next: ${prefix}${String(1).padStart(padding, "0")}`}
|
|
value={prefix}
|
|
onChange={(e) => setPrefix(e.target.value)}
|
|
/>
|
|
</Column>
|
|
<Column sm={4} md={4} lg={4}>
|
|
<NumberInput
|
|
id="seriesPadding"
|
|
label="Serial digits"
|
|
min={1}
|
|
max={8}
|
|
value={padding}
|
|
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
|
|
/>
|
|
</Column>
|
|
<Column sm={4} md={8} lg={6} style={{ display: "flex", alignItems: "flex-end" }}>
|
|
<Button onClick={startNew} disabled={busy || !prefix.trim()}>
|
|
{busy ? "Starting…" : "Start new series"}
|
|
</Button>
|
|
</Column>
|
|
</Grid>
|
|
|
|
<TableContainer title="Series history">
|
|
<Table aria-label="Invoice series">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableHeader>Prefix</TableHeader>
|
|
<TableHeader>Next number</TableHeader>
|
|
<TableHeader>Digits</TableHeader>
|
|
<TableHeader>Status</TableHeader>
|
|
<TableHeader>Created</TableHeader>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{all.map((series) => (
|
|
<TableRow key={series.id}>
|
|
<TableCell>{series.prefix}</TableCell>
|
|
<TableCell>{series.nextInvoiceNumber}</TableCell>
|
|
<TableCell>{series.padding}</TableCell>
|
|
<TableCell>
|
|
{series.isActive ? <Tag type="green">Active</Tag> : <Tag type="gray">Retired</Tag>}
|
|
</TableCell>
|
|
<TableCell>{new Date(series.createdAt).toLocaleDateString("en-GB")}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</div>
|
|
);
|
|
}
|