- 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.
543 lines
18 KiB
TypeScript
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 & payments</Tab>
|
|
<Tab>Invoice defaults</Tab>
|
|
<Tab>Numbering</Tab>
|
|
<Tab>Templates & 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>
|
|
);
|
|
}
|