import { useEffect, useMemo, useState } from "react"; import { Button, 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 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; } 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 }: 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 load = async () => { try { setRows(await api.listInvoices()); } catch (e) { toast.error("Could not load invoices", String(e)); } finally { setLoading(false); } }; // 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 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.

{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
{sorted.length === 0 ? (
No invoices match these filters.
) : ( {COLUMNS.map((c) => ( onSort(c.key)} className={c.numeric ? "voiced-num" : undefined} > {c.label} ))} Actions {shown.map((row) => { const badge = statusBadge(row.status, row.paymentStatus); return ( onOpen(row.id)}> { 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} e.stopPropagation()}> onOpen(row.id)} /> void onExport(row.id, "searchable", "original")} /> void onExport(row.id, "flattened", "original")} /> ); })}
{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); }} />
)} )}
); }