Files
Voiced/src/components/ErpnextSettingsPanel.tsx
T
xavierk 2ffbf0f66c Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)
- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
2026-10-04 20:57:31 +05:30

592 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
ComboBox,
FileUploaderButton,
InlineLoading,
InlineNotification,
PasswordInput,
RadioButton,
RadioButtonGroup,
TextArea,
TextInput,
Tag,
Tile,
Toggle,
} from "@carbon/react";
import { Save } from "@carbon/icons-react";
import { api } from "../lib/api";
import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext";
import {
OPTION_LIST_LABELS,
UOM_UNITS,
connectionProblems,
emptyOptions,
hasProblems,
mirrorSupported,
optionErrors,
optionText,
sortWarnings,
toInput,
validateConfigForm,
versionsLine,
warningSeverity,
withStoredValue,
type FormProblems,
} from "../lib/erpnextUi";
import { useToast } from "./ToastProvider";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
interface PickProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
/** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */
items: ErpnextOptionItem[];
/** Why this list is empty although the lists were loaded (a load error for this list). */
error?: string;
/** The control is switched off, with the reason shown under it. */
disabledReason?: string;
helper?: string;
}
/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */
function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) {
const options = useMemo(() => withStoredValue(items, value), [items, value]);
const note = disabledReason ?? error ?? helper;
if (items.length === 0 || disabledReason) {
return (
<TextInput
id={id}
labelText={label}
value={value}
disabled={Boolean(disabledReason)}
invalid={Boolean(error) && !disabledReason}
invalidText={error}
helperText={error ? undefined : note}
onChange={(e) => onChange(e.target.value)}
/>
);
}
return (
<ComboBox
id={id}
titleText={label}
items={options}
itemToString={(i: ErpnextOptionItem | null) => (i ? optionText(i) : "")}
selectedItem={options.find((o) => o.name === value) ?? null}
onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")}
shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) =>
!inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase())
}
helperText={helper}
placeholder="Search or choose"
/>
);
}
function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) {
return (
<section className="voiced-erp__section" aria-label={title}>
<h3>{title}</h3>
{note ? <p className="voiced-muted">{note}</p> : null}
<div className="voiced-erp__fields">{children}</div>
</section>
);
}
/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */
export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
const toast = useToast();
const [saved, setSaved] = useState<ErpnextConfig | null>(null);
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
/** A test result from this session that is not saved yet. */
const [testUnsaved, setTestUnsaved] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [options, setOptions] = useState<ErpnextOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [saving, setSaving] = useState(false);
const [showProblems, setShowProblems] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const cfg = await api.erpnextGetConfig();
setSaved(cfg);
setForm(toInput(cfg));
setTest(cfg.lastDetectResult);
setTestUnsaved(false);
setLoadError(null);
} catch (e) {
setLoadError(String(e));
}
}, []);
useEffect(() => {
if (active && form === null) void load();
// Reload only on first show; edits must survive switching tabs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
if (loadError) {
return <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
}
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
const set = <K extends keyof ErpnextConfigInput>(key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f));
const secretStored = saved.apiSecretSet;
const secretShown = secretStored && !replacing && !form.clearSecret;
const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {};
const connProblems = connectionProblems(form, secretStored);
const canConnect = !hasProblems(connProblems);
const optErrors = optionErrors(options);
const opts = options ?? emptyOptions();
const noCompany = !form.company.trim();
const companyReason = noCompany ? "Pick a company first." : undefined;
const seriesMode = form.namingMode === "series";
const mirrorOk = mirrorSupported(test);
const loadOptions = async (values: ErpnextConfigInput) => {
setLoadingOptions(true);
try {
setOptions(await api.erpnextLoadOptions(values));
} catch (e) {
setOptions(null);
toast.error("Could not load the ERPNext lists", String(e));
} finally {
setLoadingOptions(false);
}
};
const runTest = async () => {
setShowProblems(true);
if (!canConnect) return;
setTesting(true);
setTestError(null);
try {
const result = await api.erpnextTestConnection(form);
setTest(result);
setTestUnsaved(true);
let next = form;
// A site that keeps no posted name cannot mirror the Voiced number.
if (!result.features.v2Naming && form.namingMode === "mirror") {
next = { ...form, namingMode: "series" };
setForm(next);
}
await loadOptions(next);
} catch (e) {
setTestError(String(e));
} finally {
setTesting(false);
}
};
const changeCompany = (company: string) => {
const next = { ...form, company };
setForm(next);
// Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it.
if (options && canConnect) void loadOptions(next);
};
const save = async () => {
setShowProblems(true);
setSaveError(null);
if (hasProblems(validateConfigForm(form, secretStored, test))) return;
setSaving(true);
try {
const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) });
setSaved(cfg);
setForm(toInput(cfg));
setReplacing(false);
setTest(cfg.lastDetectResult ?? test);
setTestUnsaved(false);
setShowProblems(false);
toast.success("ERPNext settings saved");
} catch (e) {
const text = String(e);
setSaveError(text);
toast.error("Could not save the ERPNext settings", text);
} finally {
setSaving(false);
}
};
const readPem = async (file: File | undefined) => {
if (!file) return;
try {
set("extraCaPem", (await file.text()).trim());
} catch (e) {
toast.error("Could not read the certificate file", String(e));
}
};
const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined);
return (
<div className="voiced-stack voiced-erp">
<div>
<h2 className="voiced-detail__h2">ERPNext</h2>
<p>
Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never
needed to issue an invoice.
</p>
<p className="voiced-muted">
Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data
(Customer, Item, Address, Account, Company), and write on File. Use that user&rsquo;s API key and secret below. An address must be
https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts.
</p>
</div>
<Section title="Connection">
<TextInput
id="erp-baseUrl"
labelText="Site address"
placeholder="https://erp.example.com"
value={form.baseUrl}
invalid={Boolean(problems.baseUrl)}
invalidText={problems.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
/>
<TextInput
id="erp-apiKey"
labelText="API key"
autoComplete="off"
value={form.apiKey}
invalid={Boolean(problems.apiKey)}
invalidText={problems.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
/>
{secretShown ? (
<div className="voiced-erp__secret">
<p className="voiced-erp__label" id="erp-secret-label">
API secret
</p>
<p aria-labelledby="erp-secret-label">
<Tag type="green" size="sm">
set
</Tag>{" "}
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
</p>
<div className="voiced-header-action">
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
Replace
</Button>
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
Clear
</Button>
</div>
</div>
) : (
<div>
<PasswordInput
id="erp-apiSecret"
labelText="API secret"
autoComplete="new-password"
value={form.apiSecret}
invalid={Boolean(secretInvalid)}
invalidText={secretInvalid}
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
onChange={(e) => set("apiSecret", e.target.value)}
/>
{secretStored ? (
<Button
kind="ghost"
size="sm"
onClick={() => {
setReplacing(false);
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
}}
>
Keep the stored secret
</Button>
) : null}
</div>
)}
<div>
<TextArea
id="erp-extraCa"
labelText="Extra CA certificate (optional, PEM)"
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
rows={3}
value={form.extraCaPem}
onChange={(e) => set("extraCaPem", e.target.value)}
/>
<FileUploaderButton
labelText="Load from file…"
buttonKind="ghost"
size="sm"
accept={[".pem", ".crt", ".cer"]}
disableLabelChanges
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
void readPem(file);
}}
/>
</div>
<div className="voiced-header-action">
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
{testing ? "Testing…" : "Test connection"}
</Button>
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
</div>
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
{test ? (
<Tile className="voiced-erp__result" aria-label="Connection test result">
<p>
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
</Tag>
</p>
<p className="voiced-muted">{versionsLine(test)}</p>
{testUnsaved ? (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Save to keep this result"
subtitle="Sending invoices needs a saved connection test."
/>
) : (
<p className="voiced-muted">Last saved test result.</p>
)}
{sortWarnings(test.warnings).map((w, i) => {
const severity = warningSeverity(w.code);
return (
<InlineNotification
key={`${w.code}-${i}`}
kind={severity}
lowContrast
hideCloseButton
title={SEVERITY_TITLE[severity]}
subtitle={w.message}
/>
);
})}
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
</Tile>
) : null}
</Section>
<Section
title="Company and accounts"
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
>
{options && options.errors.length > 0 ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="Some lists could not be loaded"
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
/>
) : null}
<PickField
id="erp-company"
label="Company"
value={form.company}
onChange={changeCompany}
items={opts.companies}
error={optErrors.get("companies")}
/>
<PickField
id="erp-companyAddress"
label="Company address"
value={form.companyAddress}
onChange={(v) => set("companyAddress", v)}
items={opts.companyAddresses}
error={optErrors.get("companyAddresses")}
disabledReason={companyReason}
helper="Required by India Compliance on every invoice."
/>
<PickField
id="erp-incomeAccount"
label="Income account"
value={form.incomeAccount}
onChange={(v) => set("incomeAccount", v)}
items={opts.incomeAccounts}
error={optErrors.get("incomeAccounts")}
disabledReason={companyReason}
/>
<PickField
id="erp-costCenter"
label="Cost center"
value={form.costCenter}
onChange={(v) => set("costCenter", v)}
items={opts.costCenters}
error={optErrors.get("costCenters")}
disabledReason={companyReason}
/>
{(
[
["cgstAccount", "CGST account"],
["sgstAccount", "SGST account"],
["utgstAccount", "UTGST account"],
["igstAccount", "IGST account"],
] as const
).map(([key, label]) => (
<PickField
key={key}
id={`erp-${key}`}
label={label}
value={form[key]}
onChange={(v) => set(key, v)}
items={opts.taxAccounts}
error={optErrors.get("taxAccounts")}
disabledReason={companyReason}
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
/>
))}
<TextInput
id="erp-paymentBank"
labelText="Payment bank account"
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
value={form.paymentBankAccount}
onChange={(e) => set("paymentBankAccount", e.target.value)}
/>
<TextInput
id="erp-tdsAccount"
labelText="TDS account"
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
value={form.tdsAccount}
onChange={(e) => set("tdsAccount", e.target.value)}
/>
</Section>
<Section title="Customers and items">
<TextInput
id="erp-defaultItem"
labelText="Default item code (optional)"
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
value={form.defaultItemCode}
onChange={(e) => set("defaultItemCode", e.target.value)}
/>
<PickField
id="erp-customerGroup"
label="Customer group for new customers"
value={form.customerGroup}
onChange={(v) => set("customerGroup", v)}
items={opts.customerGroups}
error={optErrors.get("customerGroups")}
/>
<PickField
id="erp-territory"
label="Territory for new customers"
value={form.territory}
onChange={(v) => set("territory", v)}
items={opts.territories}
error={optErrors.get("territories")}
/>
<PickField
id="erp-priceList"
label="Selling price list"
value={form.sellingPriceList}
onChange={(v) => set("sellingPriceList", v)}
items={opts.priceLists}
error={optErrors.get("priceLists")}
/>
<fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
{UOM_UNITS.map((unit) => (
<PickField
key={unit}
id={`erp-uom-${unit}`}
label={`Voiced ${unit}`}
value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms}
error={optErrors.get("uoms")}
/>
))}
</fieldset>
</Section>
<Section title="Invoice numbers">
<RadioButtonGroup
legendText="Numbering in ERPNext"
name="erp-namingMode"
orientation="vertical"
valueSelected={form.namingMode}
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
>
<RadioButton
id="erp-nm-mirror"
value="mirror"
labelText="Mirror the Voiced number (recommended)"
disabled={!mirrorOk}
/>
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
</RadioButtonGroup>
<p className="voiced-muted">
{mirrorOk
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
</p>
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
<PickField
id="erp-namingSeries"
label="Naming series"
value={form.namingSeries}
onChange={(v) => set("namingSeries", v)}
items={opts.namingSeries}
error={problems.namingSeries ?? optErrors.get("namingSeries")}
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
/>
</Section>
<Section title="Sending">
<Toggle
id="erp-submit"
labelText="Submit invoices when sending"
labelA="Off: drafts for review"
labelB="On: submitted"
toggled={form.submitOnPush}
onToggle={(v) => set("submitOnPush", v)}
/>
<Toggle
id="erp-attach"
labelText="Attach the PDF"
labelA="Off"
labelB="On"
toggled={form.attachPdf}
onToggle={(v) => set("attachPdf", v)}
/>
<Toggle
id="erp-auto"
labelText="Send automatically when an invoice is issued"
labelA="Off"
labelB="On"
toggled={form.autoPushOnIssue}
onToggle={(v) => set("autoPushOnIssue", v)}
/>
<Toggle
id="erp-customers"
labelText="Create customers that are missing in ERPNext"
labelA="Off"
labelB="On"
toggled={form.createMissingCustomers}
onToggle={(v) => set("createMissingCustomers", v)}
/>
</Section>
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
<div>
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : "Save ERPNext settings"}
</Button>
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
</div>
</div>
);
}