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.
This commit is contained in:
2026-10-04 20:57:31 +05:30
parent d3b5fbb1b8
commit 2ffbf0f66c
24 changed files with 2855 additions and 11 deletions
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
import { api } from "../lib/api";
import type { ErpnextPushResult } from "../lib/erpnext";
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
export interface SendTarget {
id: number;
number: string;
}
interface Props {
open: boolean;
invoices: SendTarget[];
/** Cancelled or draft invoices that were left out of the selection. */
skipped?: number;
/** The "submit on push" setting; the user still chooses explicitly. */
defaultSubmit: boolean;
onClose: () => void;
/** Called once when a send has finished, so the list can refresh its statuses. */
onFinished: (results: ErpnextPushResult[]) => void;
}
type Phase = "confirm" | "running" | "done";
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
const [submit, setSubmit] = useState(defaultSubmit);
const [phase, setPhase] = useState<Phase>("confirm");
const [results, setResults] = useState<ErpnextPushResult[]>([]);
const alive = useRef(true);
useReturnFocus(open);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
if (!open) return;
setSubmit(defaultSubmit);
setPhase("confirm");
setResults([]);
}, [open, defaultSubmit]);
const run = async () => {
setPhase("running");
const out: ErpnextPushResult[] = [];
for (const inv of invoices) {
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(inv.id, submit);
} catch (e) {
// Not set up, or the command itself failed: this row fails, the others still go.
result = failedPush(inv.id, inv.number, e);
}
out.push(result);
if (alive.current) setResults([...out]);
}
if (alive.current) setPhase("done");
onFinished(out);
};
const count = invoices.length;
const noun = count === 1 ? "invoice" : "invoices";
if (phase === "confirm") {
return (
<Modal
open={open}
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
primaryButtonText={`Send ${count} ${noun}`}
secondaryButtonText="Cancel"
primaryButtonDisabled={count === 0}
onRequestClose={onClose}
onRequestSubmit={() => void run()}
>
<div className="voiced-stack">
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
</RadioButtonGroup>
<p className="voiced-muted">
An invoice that is already in ERPNext is never overwritten or created twice.
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
</p>
</div>
</Modal>
);
}
const summary = phase === "done" ? summarizeBatch(results) : null;
return (
<Modal
open={open}
passiveModal
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
preventCloseOnClickOutside
onRequestClose={() => {
if (phase === "done") onClose();
}}
>
<div className="voiced-stack">
{phase === "running" ? (
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
) : (
<p role="status">
<strong>{summary?.title}</strong>
</p>
)}
<ul className="voiced-erp__results" aria-label="Results">
{results.map((r) => {
const note = describePush(r);
return (
<li key={r.invoiceId}>
<Tag type={TAG[note.kind]} size="sm">
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
</Tag>{" "}
<strong>{r.number || `#${r.invoiceId}`}</strong>
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
</li>
);
})}
</ul>
{phase === "done" ? (
<Button kind="primary" size="md" onClick={onClose}>
Close
</Button>
) : null}
</div>
</Modal>
);
}