Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled to 1200 px and re-encoded as a content-addressed print PNG plus a white knockout variant when the logo has alpha; aspect, ink density, mean colour and kind (wordmark, mark, tall) are stored with the settings and frozen in the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import {
|
|
Button,
|
|
Column,
|
|
Grid,
|
|
InlineNotification,
|
|
Link,
|
|
NumberInput,
|
|
RadioButton,
|
|
RadioButtonGroup,
|
|
Select,
|
|
SelectItem,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
TextArea,
|
|
TextInput,
|
|
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";
|
|
|
|
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 [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 registered = form.gstRegistration !== "unregistered";
|
|
const gstinProblem = vendorGstinError(
|
|
form.gstRegistration,
|
|
form.vendorGstin,
|
|
form.vendorPan,
|
|
form.vendorStateCode,
|
|
);
|
|
|
|
const saveAll = async () => {
|
|
if (gstinProblem) {
|
|
setError(gstinProblem);
|
|
return;
|
|
}
|
|
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}>
|
|
<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>
|
|
|
|
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
|
<h3>Branding</h3>
|
|
<Tile>
|
|
<LogoBranding
|
|
idPrefix="s"
|
|
value={form}
|
|
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
|
disabled={busy}
|
|
/>
|
|
</Tile>
|
|
</section>
|
|
|
|
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
|
<h3>Defaults</h3>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
);
|
|
}
|