Files
Voiced/src/views/InvoiceHistory.tsx
T
xavierk 2ffbf0f66c 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.
2026-10-04 20:57:31 +05:30

527 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Button,
Checkbox,
InlineLoading,
Link,
OverflowMenu,
OverflowMenuItem,
Pagination,
Search,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tag,
Tile,
} from "@carbon/react";
import { save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
import { formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { statusBadge } from "../lib/payments";
import { currentFyStartYear, FY_MONTHS, fyLabel } from "../lib/fiscal";
import {
clientOptions,
filterInvoices,
fyOptions,
fySummary,
NO_FILTERS,
sortInvoices,
STATUS_FILTERS,
totalsOf,
type HistoryFilters,
type SortKey,
type SortSpec,
type StatusFilter,
} from "../lib/historyFilter";
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";
interface Props {
settings: Settings;
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];
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
{ key: "number", label: "Number" },
{ key: "date", label: "Date" },
{ key: "client", label: "Client" },
{ key: "total", label: "Total", numeric: true },
{ key: "balance", label: "Balance", numeric: true },
{ key: "status", label: "Status" },
];
export default function InvoiceHistory({ settings, active, onOpen, onOpenSettings }: Props) {
const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [filters, setFilters] = useState<HistoryFilters>(NO_FILTERS);
const [sort, setSort] = useState<SortSpec>({ key: "date", dir: "desc" });
const [page, setPage] = useState(1);
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 {
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).
useEffect(() => {
if (active) void load();
}, [active]);
const patch = (change: Partial<HistoryFilters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
const currentFy = currentFyStartYear();
const tileFy = filters.fy === "all" ? currentFy : filters.fy;
const filtered = useMemo(() => filterInvoices(rows, filters), [rows, filters]);
const sorted = useMemo(() => sortInvoices(filtered, sort), [filtered, sort]);
const totals = useMemo(() => totalsOf(filtered), [filtered]);
const summary = useMemo(() => fySummary(rows, tileFy), [rows, tileFy]);
const years = useMemo(() => fyOptions(rows, currentFy), [rows, currentFy]);
const clients = useMemo(() => clientOptions(rows), [rows]);
const filtersActive = JSON.stringify(filters) !== JSON.stringify(NO_FILTERS);
const lastPage = Math.max(1, Math.ceil(sorted.length / pageSize));
const shown = sorted.slice((Math.min(page, lastPage) - 1) * pageSize, Math.min(page, lastPage) * pageSize);
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 {
// Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source);
} catch (e) {
toast.error("Export failed", String(e));
} finally {
setBusyId(null);
}
};
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
const onExportHistory = async (format: HistoryFormat) => {
setExportingHistory(true);
try {
const done = await exportHistory(
{
listLedger: api.listInvoiceLedger,
getLastExportDir: api.getLastExportDir,
save: ({ defaultPath, format: fmt }) =>
save({
defaultPath,
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
}),
writeExportFile: api.writeExportFile,
now: () => new Date(),
},
sorted,
filters,
format,
);
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
} catch (e) {
toast.error("Could not export the invoices", String(e));
} finally {
setExportingHistory(false);
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ExportFeedback exp={exp} />
{loading ? (
<InlineLoading description="Loading invoices" />
) : rows.length === 0 ? (
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
) : (
<>
<section aria-label={`Summary for ${fyLabel(tileFy)}`}>
<h2 className="voiced-history__fy">{fyLabel(tileFy)}</h2>
<div className="voiced-history__tiles">
<Tile>
<p className="voiced-muted">Invoiced</p>
<p className="voiced-history__amount">{formatPaise(summary.invoicedPaise)}</p>
<p className="voiced-muted">{summary.count} invoices</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-history__amount">{formatPaise(summary.receivedPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-history__amount">{formatPaise(summary.tdsPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Outstanding</p>
<p className="voiced-history__amount">{formatPaise(summary.outstandingPaise)}</p>
</Tile>
<Tile className={summary.overdueCount > 0 ? "voiced-history__tile--overdue" : undefined}>
<p className="voiced-muted">Overdue</p>
<p className="voiced-history__amount">{formatPaise(summary.overduePaise)}</p>
<p className="voiced-muted">{summary.overdueCount} invoices</p>
</Tile>
</div>
</section>
<div className="voiced-history__filters" role="search">
<Search
id="historySearch"
size="md"
labelText="Search invoices"
placeholder="Number, client or PO number"
value={filters.search}
onChange={(e) => patch({ search: e.target.value })}
onClear={() => patch({ search: "" })}
/>
<Select id="historyStatus" size="md" labelText="Status" value={filters.status} onChange={(e) => patch({ status: e.target.value as StatusFilter })}>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s.id} value={s.id} text={s.label} />
))}
</Select>
<Select
id="historyFy"
size="md"
labelText="Financial year"
value={String(filters.fy)}
onChange={(e) => patch({ fy: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All years" />
{years.map((y) => (
<SelectItem key={y} value={String(y)} text={fyLabel(y)} />
))}
</Select>
<Select
id="historyMonth"
size="md"
labelText="Month"
value={String(filters.month)}
onChange={(e) => patch({ month: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All months" />
{FY_MONTHS.map((m) => (
<SelectItem key={m.month} value={String(m.month)} text={m.label} />
))}
</Select>
<Select id="historyClient" size="md" labelText="Client" value={filters.client} onChange={(e) => patch({ client: e.target.value })}>
<SelectItem value="all" text="All clients" />
{clients.map((c) => (
<SelectItem key={c} value={c} text={c} />
))}
</Select>
{filtersActive ? (
<Button kind="ghost" size="md" onClick={() => patch({ ...NO_FILTERS })}>
Clear filters
</Button>
) : null}
</div>
<div className="voiced-history__export">
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
Export CSV
</Button>
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
Export JSON
</Button>
<span className="voiced-muted">
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
</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>
) : (
<TableContainer>
<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}
isSortable
isSortHeader={sort.key === c.key}
sortDirection={sort.key === c.key ? (sort.dir === "asc" ? "ASC" : "DESC") : "NONE"}
onClick={() => onSort(c.key)}
className={c.numeric ? "voiced-num" : undefined}
>
{c.label}
</TableHeader>
))}
{showErpColumn ? <TableHeader>ERPNext</TableHeader> : null}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<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="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen(row.id);
}}
>
{row.number}
</Link>
</TableCell>
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell className="voiced-num">{formatPaise(row.totalPaise)}</TableCell>
<TableCell className="voiced-num">{row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)}</TableCell>
<TableCell>
<Tag type={badge.type} size="sm">
{badge.label}
</Tag>
{row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</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}`}
iconDescription="Actions"
flipped
size="sm"
disabled={busyId !== null || exp.busy}
>
<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>
);
})}
</TableBody>
</Table>
<div className="voiced-history__totals" role="status" aria-label="Totals for the filtered invoices">
<span className="voiced-muted">
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices
</span>
<span>
Invoiced <strong>{formatPaise(totals.invoicedPaise)}</strong>
</span>
<span>
Received <strong>{formatPaise(totals.receivedPaise)}</strong>
</span>
<span>
TDS <strong>{formatPaise(totals.tdsPaise)}</strong>
</span>
<span>
Outstanding <strong>{formatPaise(totals.outstandingPaise)}</strong>
</span>
</div>
<Pagination
page={Math.min(page, lastPage)}
pageSize={pageSize}
pageSizes={PAGE_SIZES}
totalItems={sorted.length}
onChange={({ page: p, pageSize: s }: { page: number; pageSize: number }) => {
setPage(s !== pageSize ? 1 : p);
setPageSize(s);
}}
/>
</TableContainer>
)}
</>
)}
{!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>
);
}