Files
Voiced/src/views/AppSettings.tsx
T
xavierk bd83326d54 Add Rust logo pipeline: trim, metrics, knockout and Branding settings
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).
2026-10-04 06:31:30 +05:30

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>
);
}