Files
Voiced/src/views/AppSettings.tsx
T
soubarna 5b18db3a77 fix: eslint findings
Remove dead code and unused imports, drop useless assignments, and tune
rules in eslint.config.js (underscore-prefixed unused names, intentional BOM/NBSP,
React Compiler rules from eslint-plugin-react-hooks 7 as warnings).
2026-10-05 17:29:03 +05:30

470 lines
16 KiB
TypeScript

import { useEffect, useRef, useState, type MouseEvent } 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,
} 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, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DataBackupPanel from "../components/DataBackupPanel";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import ErpnextSettingsPanel from "../components/ErpnextSettingsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
import BankAccountModal from "../components/BankAccountModal";
import SeriesSettings from "./SeriesSettings";
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 [bankModalOpen, setBankModalOpen] = useState(false);
const [editing, setEditing] = useState<BankAccount | null>(null);
const bankLauncher = useRef<HTMLElement | 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 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 openBankEditor = (bank: BankAccount | null, trigger: HTMLElement) => {
bankLauncher.current = trigger;
setEditing(bank);
setBankModalOpen(true);
};
const onBankSaved = (saved: BankAccount) => {
if (saved.isDefault || form.defaultBankId === null) {
setForm((prev) => ({ ...prev, defaultBankId: saved.id }));
}
setBankModalOpen(false);
void loadBanks();
toast.info("Click Save settings to keep a changed default account.");
};
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, bank accounts and integrations.</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>
<Tab>Integrations</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" }}>
<h3>Bank accounts</h3>
{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={(e: MouseEvent<HTMLElement>) => openBankEditor({ ...bank }, e.currentTarget)}>
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>
)}
<Button
kind="ghost"
size="sm"
renderIcon={Add}
onClick={(e: MouseEvent<HTMLElement>) => openBankEditor(null, e.currentTarget)}
>
Add bank account
</Button>
<BankAccountModal
open={bankModalOpen}
bank={editing}
launcherRef={bankLauncher}
onClose={() => setBankModalOpen(false)}
onSaved={onBankSaved}
/>
</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>
<DataBackupPanel settings={settings} onRerunSetup={onSaved} />
</TabPanel>
<TabPanel>
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
</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>
);
}