- 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.
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|