- 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.
592 lines
22 KiB
TypeScript
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’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>
|
|
);
|
|
}
|