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([]); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [filters, setFilters] = useState(NO_FILTERS); const [sort, setSort] = useState({ 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(null); const [syncs, setSyncs] = useState>(new Map()); const [selected, setSelected] = useState>(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) => { 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 (

Invoices

Every invoice you have issued. Open one to see its PDF, record payments or export it.

The export result could not be shown ({e.message}). The PDF was still saved.

}>
{loading ? ( ) : rows.length === 0 ? (
No invoices yet. Create one from “New invoice”.
) : ( <>

{fyLabel(tileFy)}

Invoiced

{formatPaise(summary.invoicedPaise)}

{summary.count} invoices

Received

{formatPaise(summary.receivedPaise)}

TDS deducted

{formatPaise(summary.tdsPaise)}

Outstanding

{formatPaise(summary.outstandingPaise)}

0 ? "voiced-history__tile--overdue" : undefined}>

Overdue

{formatPaise(summary.overduePaise)}

{summary.overdueCount} invoices

patch({ search: e.target.value })} onClear={() => patch({ search: "" })} /> {filtersActive ? ( ) : null}
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
{selected.size} selected {selected.size > 0 ? ( ) : null} {blockedReason !== null && selected.size > 0 ? ( {blockedReason}{" "} { e.preventDefault(); onOpenSettings(); }} > Open Settings ) : null}
{sorted.length === 0 ? (
No invoices match these filters.
) : ( togglePage(checked)} /> {COLUMNS.map((c) => ( onSort(c.key)} className={c.numeric ? "voiced-num" : undefined} > {c.label} ))} {showErpColumn ? ERPNext : null} Actions {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 ( onOpen(row.id)}> e.stopPropagation()}> toggleOne(row.id, checked)} /> { e.preventDefault(); e.stopPropagation(); onOpen(row.id); }} > {row.number} {formatDate(row.invoiceDate)} {row.clientName || "—"} {formatPaise(row.totalPaise)} {row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)} {badge.label} {row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? ( Partly paid ) : null} {showErpColumn ? ( {erpTag.label} ) : null} e.stopPropagation()}> onOpen(row.id)} /> void onExport(row.id, "searchable", "original")} /> void onExport(row.id, "flattened", "original")} /> void generic.run([row.id], "json")} /> {erpOn || syncs.size > 0 ? ( askSend([row])} /> ) : null} {canOpenInErpnext(sync) && erpOn ? void onOpenErpnext(row.id)} /> : null} ); })}
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices Invoiced {formatPaise(totals.invoicedPaise)} Received {formatPaise(totals.receivedPaise)} TDS {formatPaise(totals.tdsPaise)} Outstanding {formatPaise(totals.outstandingPaise)}
{ setPage(s !== pageSize ? 1 : p); setPageSize(s); }} />
)} )} {!erpOn && !loading && rows.length > 0 ? (

ERPNext is not set up.{" "} { e.preventDefault(); onOpenSettings(); }} > Set it up in Settings, Integrations {" "} to send invoices to it.

) : null} setSendTargets(null)} onFinished={() => void loadErpnext()} />
); }