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 ( onChange(e.target.value)} /> ); } return ( (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 (

{title}

{note ?

{note}

: null}
{children}
); } /** 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(null); const [form, setForm] = useState(null); const [loadError, setLoadError] = useState(null); const [replacing, setReplacing] = useState(false); const [test, setTest] = useState(null); /** A test result from this session that is not saved yet. */ const [testUnsaved, setTestUnsaved] = useState(false); const [testError, setTestError] = useState(null); const [testing, setTesting] = useState(false); const [options, setOptions] = useState(null); const [loadingOptions, setLoadingOptions] = useState(false); const [saving, setSaving] = useState(false); const [showProblems, setShowProblems] = useState(false); const [saveError, setSaveError] = useState(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 ; } if (!form || !saved) return ; const set = (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 (

ERPNext

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.

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.

set("baseUrl", e.target.value)} /> set("apiKey", e.target.value)} /> {secretShown ? (

API secret

set {" "} The secret is stored on this computer and is never shown again.

) : (
set("apiSecret", e.target.value)} /> {secretStored ? ( ) : null}
)}