Files
Voiced/src/views/InvoiceHistory.tsx
T
xavierk cd067ad584 Fix the blank window after an export
Every successful export left the window blank and unresponsive, so the app had to be killed. The "Saved to ..." notice
(and the export-failed notice) held buttons inside a Carbon InlineNotification. Carbon throws "component should have no
interactive child nodes" in an effect, and an uncaught throw makes React unmount the whole tree. Found by driving the
real debug binary through WebKitWebDriver: the page body was empty and the error listener showed that throw.

- The buttons now sit beside the notice, and the failed-export notice uses ActionableNotification's own Retry action.
- ErrorBoundary around the app (reload screen instead of a blank window) and around each export notice.
- A lint test that fails if a Carbon notification wraps an interactive element.
- The real-webview self-test mounts the saved and failed notices and fails on an uncaught React error, and issues and
  exports a second invoice after the first.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:10 +05:30

530 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 ErrorBoundary from "../components/ErrorBoundary";
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>
<ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
{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>
);
}