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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user