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.
This commit is contained in:
+133
-101
@@ -3,7 +3,6 @@ import {
|
||||
Button,
|
||||
Column,
|
||||
Grid,
|
||||
InlineNotification,
|
||||
Link,
|
||||
NumberInput,
|
||||
RadioButton,
|
||||
@@ -19,6 +18,11 @@ import {
|
||||
TableRow,
|
||||
TextArea,
|
||||
TextInput,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Tile,
|
||||
Toggle,
|
||||
} from "@carbon/react";
|
||||
@@ -37,6 +41,9 @@ 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,
|
||||
@@ -61,14 +68,20 @@ export default function AppSettings({
|
||||
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 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) {
|
||||
setError(String(e));
|
||||
toast.error("Could not load bank accounts", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,18 +105,17 @@ export default function AppSettings({
|
||||
|
||||
const saveAll = async () => {
|
||||
if (gstinProblem) {
|
||||
setError(gstinProblem);
|
||||
selectTab(0);
|
||||
toast.error("Settings not saved", gstinProblem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.saveSettings(form);
|
||||
setNotice("Settings saved.");
|
||||
toast.success("Settings saved");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save settings", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -112,7 +124,6 @@ export default function AppSettings({
|
||||
const saveBank = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await api.saveBank(editing);
|
||||
if (saved.isDefault || form.defaultBankId === null) {
|
||||
@@ -120,8 +131,9 @@ export default function AppSettings({
|
||||
}
|
||||
setEditing(null);
|
||||
await loadBanks();
|
||||
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
toast.error("Could not save bank account", String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -139,30 +151,29 @@ export default function AppSettings({
|
||||
if (form.defaultBankId === bank.id) set("defaultBankId", null);
|
||||
await loadBanks();
|
||||
} catch (e) {
|
||||
setError(String(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">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}
|
||||
<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" }}>
|
||||
<h3>Vendor details</h3>
|
||||
<Grid narrow>
|
||||
<Column sm={4} md={8} lg={12}>
|
||||
<RadioButtonGroup
|
||||
@@ -277,9 +288,9 @@ export default function AppSettings({
|
||||
</Column>
|
||||
</Grid>
|
||||
</section>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Branding</h3>
|
||||
<Tile>
|
||||
<LogoBranding
|
||||
idPrefix="s"
|
||||
@@ -289,78 +300,8 @@ export default function AppSettings({
|
||||
/>
|
||||
</Tile>
|
||||
</section>
|
||||
|
||||
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
|
||||
<h3>Fonts</h3>
|
||||
<Tile>
|
||||
<FontOverrideTable />
|
||||
</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>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<section className="voiced-stack" style={{ marginBottom: "2rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3>Bank accounts</h3>
|
||||
@@ -487,6 +428,97 @@ export default function AppSettings({
|
||||
</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"}
|
||||
|
||||
Reference in New Issue
Block a user