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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user