Files
Voiced/src/views/AppSettings.tsx
T
xavierk db2075c409 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.
2026-10-04 17:29:46 +05:30

543 lines
18 KiB
TypeScript

import { useEffect, useState } from "react";
import {
Button,
Column,
Grid,
Link,
NumberInput,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TextArea,
TextInput,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Tile,
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 GstRegistration,
type Settings,
type TaxType,
} from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
import SeriesSettings from "./SeriesSettings";
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 [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
// One form state for every tab and one Save button, so edits survive switching tabs.
const [tab, setTab] = useState(loadSettingsTab);
const selectTab = (index: number) => {
setTab(index);
saveSettingsTab(index);
};
const loadBanks = async () => {
try {
setBanks(await api.listBanks());
} catch (e) {
toast.error("Could not load bank accounts", 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 registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const saveAll = async () => {
if (gstinProblem) {
selectTab(0);
toast.error("Settings not saved", gstinProblem);
return;
}
setBusy(true);
try {
await api.saveSettings(form);
toast.success("Settings saved");
onSaved();
} catch (e) {
toast.error("Could not save settings", String(e));
} finally {
setBusy(false);
}
};
const saveBank = async () => {
if (!editing) return;
setBusy(true);
try {
const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) {
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
}
setEditing(null);
await loadBanks();
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
} catch (e) {
toast.error("Could not save bank account", 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) {
toast.error("Could not delete bank account", String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p>
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
<TabList aria-label="Settings sections" contained scrollIntoView>
<Tab>Business</Tab>
<Tab>Branding</Tab>
<Tab>Bank &amp; payments</Tab>
<Tab>Invoice defaults</Tab>
<Tab>Numbering</Tab>
<Tab>Templates &amp; export</Tab>
<Tab>Fonts</Tab>
<Tab>Data</Tab>
</TabList>
<TabPanels>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="GST registration"
name="s-gstReg"
orientation="horizontal"
valueSelected={form.gstRegistration}
onChange={(value) => {
set("gstRegistration", value as GstRegistration);
if (value === "unregistered") set("defaultTaxType", "none");
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
}}
>
<RadioButton id="s-gr-un" labelText="Unregistered" value="unregistered" />
<RadioButton id="s-gr-reg" labelText="Regular" value="regular" />
</RadioButtonGroup>
</Column>
<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>
{registered ? (
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
) : null}
<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={4} lg={6}>
<TextInput
id="s-signatoryName"
labelText="Authorised signatory name"
value={form.signatoryName}
onChange={(e) => set("signatoryName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-signatoryDesignation"
labelText="Signatory designation"
value={form.signatoryDesignation}
onChange={(e) => set("signatoryDesignation", e.target.value)}
/>
</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>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Tile>
<LogoBranding
idPrefix="s"
value={form}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
disabled={busy}
/>
</Tile>
</section>
</TabPanel>
<TabPanel>
<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>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Grid narrow>
{registered ? (
<>
<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}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<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>
{registered ? (
<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>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>
The template, page size and margins are chosen next to the preview on the New invoice screen. The
default page setup is saved from there.
</p>
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Tile>
<FontOverrideTable />
</Tile>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>Backup and export arrive in the next step.</p>
</section>
</TabPanel>
</TabPanels>
</Tabs>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"}
</Button>
<p className="voiced-muted" style={{ marginTop: "3rem", fontSize: "0.75rem" }}>
<Link
href="#"
size="sm"
onClick={(e: { preventDefault: () => void }) => {
e.preventDefault();
setDiagnosticsOpen(true);
}}
>
Diagnostics
</Link>
</p>
<DiagnosticsPanel open={diagnosticsOpen} onClose={() => setDiagnosticsOpen(false)} />
</div>
);
}