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
+217 -3
View File
@@ -3,6 +3,7 @@ import {
Button,
InlineLoading,
InlineNotification,
Link,
Modal,
OverflowMenu,
OverflowMenuItem,
@@ -28,6 +29,20 @@ import { toDraftPayload } from "../lib/draft";
import { invoiceToFormState } from "../lib/duplicate";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useGenericExport } from "../hooks/useGenericExport";
import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextPushResult, ErpnextSyncStatus } from "../lib/erpnext";
import {
canOpenInErpnext,
describePaymentPush,
describePush,
failedPush,
formatSyncedAt,
isConfigured,
paymentPushDisabledReason,
pushDisabledReason,
syncTag,
} from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview";
@@ -41,6 +56,8 @@ interface Props {
onBack: () => void;
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
onDuplicated: (draftId: number) => void;
/** Goes to Settings, Integrations. */
onOpenSettings: () => void;
}
interface Frozen {
@@ -74,7 +91,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
);
}
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) {
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated, onOpenSettings }: Props) {
const toast = useToast();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [payments, setPayments] = useState<Payment[]>([]);
@@ -88,6 +105,12 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false);
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
const [erpSync, setErpSync] = useState<ErpnextSyncStatus | undefined>(undefined);
const [lastPush, setLastPush] = useState<ErpnextPushResult | null>(null);
const [sendingErp, setSendingErp] = useState(false);
const [sendingPayment, setSendingPayment] = useState<number | null>(null);
const generic = useGenericExport();
useReturnFocus(deleteTarget !== null);
useReturnFocus(cancelOpen);
@@ -123,14 +146,31 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
}
}, []);
/** ERPNext is optional: when it cannot be read the panel just stays out of the way. */
const loadErp = useCallback(async (id: number, gen: number) => {
try {
const [cfg, sync] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatus(id)]);
if (gen !== generation.current) return;
setErpCfg(cfg);
setErpSync(sync);
} catch {
if (gen === generation.current) {
setErpCfg(null);
setErpSync(undefined);
}
}
}, []);
useEffect(() => {
const gen = ++generation.current;
setInvoice(null);
setPdf(null);
setArchive(null);
setLastPush(null);
void loadData(invoiceId, gen);
void loadPdf(invoiceId, gen);
}, [invoiceId, loadData, loadPdf]);
void loadErp(invoiceId, gen);
}, [invoiceId, loadData, loadPdf, loadErp]);
const refresh = () => loadData(invoiceId, generation.current);
@@ -194,6 +234,44 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
}
};
/** `submit` undefined keeps what the invoice already is in ERPNext. A failure is shown, never thrown. */
const onSendErp = async (submit: boolean) => {
if (!invoice) return;
setSendingErp(true);
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(invoice.id, submit);
} catch (e) {
result = failedPush(invoice.id, invoice.number, e);
}
setLastPush(result);
const note = describePush(result);
toast[note.kind](note.title, note.subtitle);
await loadErp(invoiceId, generation.current);
setSendingErp(false);
};
const onOpenErp = async () => {
try {
await openInErpnext(invoiceId);
} catch (e) {
toast.error("Could not open ERPNext", String(e));
}
};
const onSendPayment = async (paymentId: number) => {
setSendingPayment(paymentId);
try {
const note = describePaymentPush(await api.erpnextPushPayment(paymentId));
toast[note.kind](note.title, note.subtitle);
await refresh();
} catch (e) {
toast.error("Could not send the payment", String(e));
} finally {
setSendingPayment(null);
}
};
const onDeletePayment = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
@@ -231,6 +309,12 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
const balance = invoice.balancePaise ?? 0;
const canPay = invoice.status === "issued" && balance > 0;
const half = invoice.taxRate / 2;
const erpOn = isConfigured(erpCfg);
const erpTag = syncTag(erpSync);
const erpBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered: settings.gstRegistration !== "unregistered", invoiceStatus: invoice.status });
const erpHasState = Boolean(erpSync && erpSync.status !== "none");
const erpSubmitted = erpSync?.status === "synced" && erpSync.remoteDocstatus === 1;
const showPaymentErp = erpOn || payments.some((p) => p.erpnextPaymentEntry);
return (
<div className="voiced-page voiced-detail">
@@ -284,6 +368,8 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</Button>
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([invoice.id], "json")} />
<OverflowMenuItem itemText="Export line items (CSV)" onClick={() => void generic.run([invoice.id], "csv")} />
{invoice.status === "issued" ? (
<OverflowMenuItem
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
@@ -416,6 +502,104 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
</section>
<section aria-labelledby="detail-erpnext">
<h2 id="detail-erpnext" className="voiced-detail__h2">
ERPNext
</h2>
{!erpOn && !erpHasState ? (
<p className="voiced-muted">
ERPNext is not set up.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Set it up in Settings, Integrations
</Link>
.
</p>
) : (
<div className="voiced-erp__panel">
<dl>
<Field label="Status">
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
{erpTag.label}
</Tag>
</Field>
{erpSync?.remoteName ? <Field label="ERPNext document">{erpSync.remoteName}</Field> : null}
{erpSync?.syncedAt ? <Field label="Last synced">{formatSyncedAt(erpSync.syncedAt)}</Field> : null}
{erpSync?.status === "synced" ? <Field label="PDF">{erpSync.attached ? "Attached in ERPNext" : "Not attached"}</Field> : null}
</dl>
{erpSync && (erpSync.status === "error" || erpSync.status === "conflict") && erpSync.lastError ? (
<InlineNotification
kind={erpSync.status === "conflict" ? "warning" : "error"}
lowContrast
hideCloseButton
title={erpSync.status === "conflict" ? "Conflict with ERPNext" : "The last send failed"}
subtitle={erpSync.lastError}
/>
) : null}
{lastPush && lastPush.warnings.length > 0 ? (
<InlineNotification kind="warning" lowContrast hideCloseButton title="Sent with notes" subtitle={lastPush.warnings.join(" ")} />
) : null}
{erpOn ? (
<>
<div className="voiced-header-action">
{erpSubmitted ? (
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
{sendingErp ? "Sending…" : "Re-send"}
</Button>
) : (
<>
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(false)}>
{sendingErp ? "Sending…" : erpHasState ? "Re-send as draft" : "Send as draft"}
</Button>
<Button kind="primary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
Send &amp; submit
</Button>
</>
)}
{canOpenInErpnext(erpSync) ? (
<Button kind="ghost" size="sm" onClick={() => void onOpenErp()}>
Open in ERPNext
</Button>
) : null}
</div>
{erpBlocked ? (
<p className="voiced-muted">
{erpBlocked}{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
) : null}
</>
) : (
<p className="voiced-muted">
ERPNext is not set up any more, so nothing can be sent.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
)}
</div>
)}
</section>
<section aria-labelledby="detail-payments">
<h2 id="detail-payments" className="voiced-detail__h2">
Payments
@@ -432,6 +616,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
<TableHeader>Reference</TableHeader>
<TableHeader className="voiced-num">Received</TableHeader>
<TableHeader className="voiced-num">TDS</TableHeader>
{showPaymentErp ? <TableHeader>ERPNext</TableHeader> : null}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
@@ -448,6 +633,35 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</TableCell>
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
{showPaymentErp ? (
<TableCell>
{p.erpnextPaymentEntry ? (
<>
<Tag type="green" size="sm">
Sent
</Tag>{" "}
<span className="voiced-muted">{p.erpnextPaymentEntry}</span>
</>
) : (
(() => {
const reason = paymentPushDisabledReason({ cfg: erpCfg, sync: erpSync, entry: p.erpnextPaymentEntry });
return (
<>
<Button
kind="ghost"
size="sm"
disabled={reason !== null || sendingPayment !== null}
onClick={() => void onSendPayment(p.id)}
>
{sendingPayment === p.id ? "Sending…" : "Send payment to ERPNext"}
</Button>
{reason ? <div className="voiced-muted">{reason}</div> : null}
</>
);
})()
)}
</TableCell>
) : null}
<TableCell>
<Button
kind="ghost"
@@ -507,7 +721,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
>
<p>
{deleteTarget
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.`
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.${deleteTarget.erpnextPaymentEntry ? ` It was sent to ERPNext as ${deleteTarget.erpnextPaymentEntry}; that entry is not removed there.` : ""}`
: ""}
</p>
</Modal>