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:
+217
-3
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user