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:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
InlineLoading,
|
||||
Link,
|
||||
OverflowMenu,
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import type { ExportSource } from "../lib/exportFlow";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { useGenericExport } from "../hooks/useGenericExport";
|
||||
import { openInErpnext } from "../lib/erpnextActions";
|
||||
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
|
||||
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
|
||||
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||
@@ -52,6 +58,8 @@ interface Props {
|
||||
active: boolean;
|
||||
/** Opens the detail page of an invoice. */
|
||||
onOpen: (id: number) => void;
|
||||
/** Goes to Settings, Integrations. */
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
@@ -65,7 +73,7 @@ const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
|
||||
{ key: "status", label: "Status" },
|
||||
];
|
||||
|
||||
export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
export default function InvoiceHistory({ settings, active, onOpen, onOpenSettings }: Props) {
|
||||
const toast = useToast();
|
||||
const [rows, setRows] = useState<InvoiceSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -76,15 +84,36 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
|
||||
const exp = useInvoiceExport({ settings, bank: null });
|
||||
const [exportingHistory, setExportingHistory] = useState(false);
|
||||
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
|
||||
const [syncs, setSyncs] = useState<Map<number, ErpnextSyncStatus>>(new Map());
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [sendTargets, setSendTargets] = useState<{ invoices: SendTarget[]; skipped: number } | null>(null);
|
||||
const generic = useGenericExport();
|
||||
|
||||
/** One bulk call for every row's ERPNext status. ERPNext is optional, so a failure here only hides the column. */
|
||||
const loadErpnext = async () => {
|
||||
try {
|
||||
const [cfg, list] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatuses()]);
|
||||
setErpCfg(cfg);
|
||||
setSyncs(indexSyncStatuses(list));
|
||||
} catch {
|
||||
setErpCfg(null);
|
||||
setSyncs(new Map());
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setRows(await api.listInvoices());
|
||||
const list = await api.listInvoices();
|
||||
setRows(list);
|
||||
// Keep only selections that still exist.
|
||||
setSelected((prev) => new Set([...prev].filter((id) => list.some((r) => r.id === id))));
|
||||
} catch (e) {
|
||||
toast.error("Could not load invoices", String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
void loadErpnext();
|
||||
};
|
||||
|
||||
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
|
||||
@@ -113,6 +142,44 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
const onSort = (key: SortKey) =>
|
||||
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
|
||||
|
||||
const erpOn = isConfigured(erpCfg);
|
||||
const vendorRegistered = settings.gstRegistration !== "unregistered";
|
||||
const showErpColumn = erpOn || syncs.size > 0;
|
||||
const selectedRows = rows.filter((r) => selected.has(r.id));
|
||||
const blockedReason = pushDisabledReason({ cfg: erpCfg, vendorRegistered });
|
||||
const pageAllSelected = shown.length > 0 && shown.every((r) => selected.has(r.id));
|
||||
const toggleOne = (id: number, on: boolean) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const togglePage = (on: boolean) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const r of shown) {
|
||||
if (on) next.add(r.id);
|
||||
else next.delete(r.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const askSend = (list: InvoiceSummary[]) => {
|
||||
const { send, skipped } = sendableSelection(list);
|
||||
if (send.length === 0) {
|
||||
toast.warning("Nothing to send", "Only issued invoices are sent to ERPNext.");
|
||||
return;
|
||||
}
|
||||
setSendTargets({ invoices: send.map((r) => ({ id: r.id, number: r.number })), skipped });
|
||||
};
|
||||
const onOpenErpnext = async (id: number) => {
|
||||
try {
|
||||
await openInErpnext(id);
|
||||
} catch (e) {
|
||||
toast.error("Could not open ERPNext", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
@@ -259,6 +326,38 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="voiced-history__bulk" role="group" aria-label="Actions for the selected invoices">
|
||||
<span aria-live="polite">{selected.size} selected</span>
|
||||
<Button kind="tertiary" size="md" disabled={selected.size === 0 || blockedReason !== null} onClick={() => askSend(selectedRows)}>
|
||||
Send selected to ERPNext
|
||||
</Button>
|
||||
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "json")}>
|
||||
Export selected (voiced.invoice.v1 JSON)
|
||||
</Button>
|
||||
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "csv")}>
|
||||
Export selected (line items CSV)
|
||||
</Button>
|
||||
{selected.size > 0 ? (
|
||||
<Button kind="ghost" size="md" onClick={() => setSelected(new Set())}>
|
||||
Clear selection
|
||||
</Button>
|
||||
) : null}
|
||||
{blockedReason !== null && selected.size > 0 ? (
|
||||
<span className="voiced-muted">
|
||||
{blockedReason}{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Link>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="voiced-empty">No invoices match these filters.</div>
|
||||
) : (
|
||||
@@ -266,6 +365,9 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
<Table aria-label="Invoices">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<Checkbox id="historySelectPage" labelText="Select all invoices on this page" hideLabel checked={pageAllSelected} onChange={(_e, { checked }) => togglePage(checked)} />
|
||||
</TableHeader>
|
||||
{COLUMNS.map((c) => (
|
||||
<TableHeader
|
||||
key={c.key}
|
||||
@@ -278,6 +380,7 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
{c.label}
|
||||
</TableHeader>
|
||||
))}
|
||||
{showErpColumn ? <TableHeader>ERPNext</TableHeader> : null}
|
||||
<TableHeader>
|
||||
<span className="cds--assistive-text">Actions</span>
|
||||
</TableHeader>
|
||||
@@ -286,8 +389,20 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
<TableBody>
|
||||
{shown.map((row) => {
|
||||
const badge = statusBadge(row.status, row.paymentStatus);
|
||||
const sync = syncs.get(row.id);
|
||||
const erpTag = syncTag(sync);
|
||||
const rowBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered, invoiceStatus: row.status });
|
||||
return (
|
||||
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
|
||||
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
id={`historySelect-${row.id}`}
|
||||
labelText={`Select invoice ${row.number}`}
|
||||
hideLabel
|
||||
checked={selected.has(row.id)}
|
||||
onChange={(_e, { checked }) => toggleOne(row.id, checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href="#"
|
||||
@@ -314,6 +429,13 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
</Tag>
|
||||
) : null}
|
||||
</TableCell>
|
||||
{showErpColumn ? (
|
||||
<TableCell>
|
||||
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
|
||||
{erpTag.label}
|
||||
</Tag>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<OverflowMenu
|
||||
aria-label={`Actions for invoice ${row.number}`}
|
||||
@@ -325,6 +447,17 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
|
||||
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
||||
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
||||
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([row.id], "json")} />
|
||||
{erpOn || syncs.size > 0 ? (
|
||||
<OverflowMenuItem
|
||||
itemText="Send to ERPNext"
|
||||
hasDivider
|
||||
disabled={rowBlocked !== null}
|
||||
title={rowBlocked ?? undefined}
|
||||
onClick={() => askSend([row])}
|
||||
/>
|
||||
) : null}
|
||||
{canOpenInErpnext(sync) && erpOn ? <OverflowMenuItem itemText="Open in ERPNext" onClick={() => void onOpenErpnext(row.id)} /> : null}
|
||||
</OverflowMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -363,6 +496,31 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!erpOn && !loading && rows.length > 0 ? (
|
||||
<p className="voiced-muted" style={{ marginTop: "1rem" }}>
|
||||
ERPNext is not set up.{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onOpenSettings();
|
||||
}}
|
||||
>
|
||||
Set it up in Settings, Integrations
|
||||
</Link>{" "}
|
||||
to send invoices to it.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SendToErpnextModal
|
||||
open={sendTargets !== null}
|
||||
invoices={sendTargets?.invoices ?? []}
|
||||
skipped={sendTargets?.skipped ?? 0}
|
||||
defaultSubmit={erpCfg?.submitOnPush ?? false}
|
||||
onClose={() => setSendTargets(null)}
|
||||
onFinished={() => void loadErpnext()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user