Initial commit: Voiced invoice desktop app

Offline-first invoicing app for Arun P, 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:
2026-10-03 23:07:51 +05:30
co-authored by CommandCodeBot
commit 3af9f3f66f
125 changed files with 13660 additions and 0 deletions
+411
View File
@@ -0,0 +1,411 @@
import { useEffect, useState } from "react";
import {
Button,
Column,
Grid,
InlineNotification,
NumberInput,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TextArea,
TextInput,
Toggle,
} from "@carbon/react";
import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
const blankBank = (): BankAccount => ({
id: null,
label: "",
bankName: "",
accountName: "",
accountNo: "",
branch: "",
ifsc: "",
isDefault: false,
});
export default function AppSettings({
settings,
onSaved,
}: {
settings: Settings;
onSaved: () => void;
}) {
const [form, setForm] = useState<Settings>({ ...settings });
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 loadBanks = async () => {
try {
setBanks(await api.listBanks());
} catch (e) {
setError(String(e));
}
};
useEffect(() => {
void loadBanks();
}, []);
const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const saveAll = async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
await api.saveSettings(form);
setNotice("Settings saved.");
onSaved();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const saveBank = async () => {
if (!editing) return;
setBusy(true);
setError(null);
try {
const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) {
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
}
setEditing(null);
await loadBanks();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const removeBank = async (bank: BankAccount) => {
if (bank.id === null) return;
const ok = await confirm(`Delete bank account “${bank.bankName}”?`, {
title: "Delete bank account",
kind: "warning",
});
if (!ok) return;
try {
await api.deleteBank(bank.id);
if (form.defaultBankId === bank.id) set("defaultBankId", null);
await loadBanks();
} catch (e) {
setError(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}
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<TextInput
id="s-vendorName"
labelText="Name on invoice"
value={form.vendorName}
onChange={(e) => set("vendorName", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<TextArea
id="s-vendorAddress"
labelText="Address"
rows={3}
value={form.vendorAddress}
onChange={(e) => set("vendorAddress", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorEmail"
labelText="Email"
value={form.vendorEmail}
onChange={(e) => set("vendorEmail", e.target.value)}
invalid={Boolean(emailError(form.vendorEmail))}
invalidText={emailError(form.vendorEmail)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorPhone"
labelText="Phone"
value={form.vendorPhone}
onChange={(e) => set("vendorPhone", e.target.value)}
invalid={Boolean(phoneError(form.vendorPhone))}
invalidText={phoneError(form.vendorPhone)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorPan"
labelText="PAN"
value={form.vendorPan}
onChange={(e) => set("vendorPan", e.target.value.toUpperCase())}
invalid={Boolean(panError(form.vendorPan))}
invalidText={panError(form.vendorPan)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN (blank if unregistered)"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<Select
id="s-vendorState"
labelText="State of supply"
value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)}
>
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="logo"
label="Vendor logo"
value={form.logoPath}
onChange={(path) => set("logoPath", path)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="signature"
label="Default signature"
value={form.signaturePath}
onChange={(path) => set("signaturePath", path)}
/>
</Column>
</Grid>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
<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}>
<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>
<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>
</Grid>
</section>
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Bank accounts</h3>
<Button kind="tertiary" size="sm" renderIcon={Add} onClick={() => setEditing(blankBank())}>
Add account
</Button>
</div>
{banks.length > 0 ? (
<TableContainer>
<Table aria-label="Bank accounts">
<TableHead>
<TableRow>
<TableHeader>Bank</TableHeader>
<TableHeader>Account name</TableHeader>
<TableHeader>Account no.</TableHeader>
<TableHeader>IFSC</TableHeader>
<TableHeader>Default</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{banks.map((bank) => (
<TableRow key={bank.id ?? bank.bankName}>
<TableCell>{bank.bankName}</TableCell>
<TableCell>{bank.accountName}</TableCell>
<TableCell>{bank.accountNo}</TableCell>
<TableCell>{bank.ifsc}</TableCell>
<TableCell>{bank.isDefault ? "Yes" : "No"}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button kind="ghost" size="sm" onClick={() => setEditing({ ...bank })}>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={() => removeBank(bank)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<div className="voiced-empty">No bank accounts yet.</div>
)}
{editing ? (
<div style={{ border: "1px solid var(--cds-border-subtle-01)", padding: "1rem" }}>
<Grid narrow>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-label"
labelText="Label"
value={editing.label}
onChange={(e) => setBank("label", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-bankName"
labelText="Bank name"
value={editing.bankName}
onChange={(e) => setBank("bankName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-accountName"
labelText="Account name"
value={editing.accountName}
onChange={(e) => setBank("accountName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-accountNo"
labelText="Account number"
value={editing.accountNo}
onChange={(e) => setBank("accountNo", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-branch"
labelText="Branch"
value={editing.branch}
onChange={(e) => setBank("branch", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="b-ifsc"
labelText="IFSC"
value={editing.ifsc}
onChange={(e) => setBank("ifsc", e.target.value.toUpperCase())}
invalid={Boolean(ifscError(editing.ifsc))}
invalidText={ifscError(editing.ifsc)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="b-default"
labelText="Use as default account"
toggled={editing.isDefault}
onToggle={(value) => setBank("isDefault", value)}
/>
</Column>
</Grid>
<div className="voiced-header-action" style={{ marginTop: "1rem" }}>
<Button size="sm" onClick={saveBank} disabled={busy}>
{busy ? "Saving…" : "Save account"}
</Button>
<Button kind="ghost" size="sm" onClick={() => setEditing(null)}>
Cancel
</Button>
</div>
</div>
) : null}
</section>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"}
</Button>
</div>
);
}