Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Test Vendor, built by Bongbetic. - Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI - SQLite (rusqlite) storage with a persistent invoice-number series - Guided first-run setup, pre-filled vendor/bank details - Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit) - GST as CGST+SGST or IGST, Indian amount-in-words - Optional signature, with a computer-generated note as fallback - Print-ready PDF export via @react-pdf/renderer - Windows cross-compile via cargo-xwin (NSIS installer) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
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";
|
||||
|
||||
export default function SeriesSettings() {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
|
||||
setActive(current);
|
||||
setAll(list);
|
||||
if (current) {
|
||||
setPrefix(current.prefix);
|
||||
setPadding(current.padding);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const created = await api.startNewSeries(prefix, padding);
|
||||
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<InlineLoading description="Loading series" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoice series</h1>
|
||||
<p className="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
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user