- 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.
369 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|