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
530 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|