Files
Voiced/src/views/InvoiceHistory.tsx
T
xavierk 92f952b136 Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 18:13:57 +05:30

369 lines
15 KiB
TypeScript

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<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 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<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 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>
{sorted.length === 0 ? (
<div className="voiced-empty">No invoices match these filters.</div>
) : (
<TableContainer>
<Table aria-label="Invoices">
<TableHead>
<TableRow>
{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>
))}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{shown.map((row) => {
const badge = statusBadge(row.status, row.paymentStatus);
return (
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
<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>
<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")} />
</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>
)}
</>
)}
</div>
);
}