Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary. - New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted. - History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page. - Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
+257
-160
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
Modal,
|
||||
Link,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
Pagination,
|
||||
Search,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -13,32 +17,62 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
Tile,
|
||||
} from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||
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 type { InvoiceSummary, Settings } from "../lib/types";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||
|
||||
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
||||
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 [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
||||
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
|
||||
});
|
||||
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 load = async () => {
|
||||
try {
|
||||
@@ -50,29 +84,31 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
}
|
||||
};
|
||||
|
||||
// The view stays mounted, so refetch every time it is shown again.
|
||||
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
|
||||
useEffect(() => {
|
||||
if (active) void load();
|
||||
}, [active]);
|
||||
|
||||
// Read each invoice's archive status in the background; it is a local call per row.
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
void (async () => {
|
||||
for (const row of rows) {
|
||||
if (stop) return;
|
||||
try {
|
||||
const status = await api.archiveStatus(row.id);
|
||||
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
|
||||
} catch {
|
||||
// Unknown stays unknown: the row just shows no tag.
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
stop = true;
|
||||
};
|
||||
}, [rows]);
|
||||
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);
|
||||
@@ -87,42 +123,10 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
}
|
||||
};
|
||||
|
||||
const onArchive = async (id: number) => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
const invoice = await api.getInvoice(id);
|
||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||
if (outcome.archived) {
|
||||
setArchived((a) => ({ ...a, [id]: true }));
|
||||
toast.success(`Invoice ${invoice.number} is archived`);
|
||||
} else {
|
||||
toast.error("Could not archive the PDF", `Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Could not archive the PDF", String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await api.cancelInvoice(cancelTarget.id, reason);
|
||||
toast.success(`Invoice ${cancelTarget.number} cancelled`);
|
||||
setCancelTarget(null);
|
||||
setReason("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setCancelTarget(null);
|
||||
toast.error("Could not cancel the invoice", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<h1 className="voiced-page__title">Invoices</h1>
|
||||
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
|
||||
<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} />
|
||||
|
||||
@@ -131,98 +135,191 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
||||
) : rows.length === 0 ? (
|
||||
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoices">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Number</TableHeader>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Client</TableHeader>
|
||||
<TableHeader>Total</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.number}</TableCell>
|
||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||
<TableCell>{row.clientName || "—"}</TableCell>
|
||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
{row.status === "cancelled" ? (
|
||||
<Tag type="red" size="sm">
|
||||
Cancelled
|
||||
</Tag>
|
||||
) : (
|
||||
row.status
|
||||
)}
|
||||
{archived[row.id] === false ? (
|
||||
<Tag type="warm-gray" size="sm">
|
||||
Not archived
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
{archived[row.id] === false ? (
|
||||
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
|
||||
{busyId === row.id ? "Working…" : "Render & archive"}
|
||||
</Button>
|
||||
) : null}
|
||||
<OverflowMenu
|
||||
aria-label={`Actions for invoice ${row.number}`}
|
||||
iconDescription="Actions"
|
||||
flipped
|
||||
size="sm"
|
||||
disabled={busyId !== null || exp.busy}
|
||||
>
|
||||
<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="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
|
||||
{row.status === "issued" ? (
|
||||
<OverflowMenuItem
|
||||
itemText="Cancel invoice"
|
||||
hasDivider
|
||||
isDelete
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelTarget(row);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
<>
|
||||
<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>
|
||||
|
||||
<Modal
|
||||
open={cancelTarget !== null}
|
||||
danger
|
||||
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
|
||||
primaryButtonText="Cancel invoice"
|
||||
secondaryButtonText="Keep invoice"
|
||||
onRequestClose={() => setCancelTarget(null)}
|
||||
onRequestSubmit={onCancel}
|
||||
>
|
||||
<p style={{ marginBottom: "1rem" }}>
|
||||
The invoice stays in the list as cancelled and its number is not reused.
|
||||
</p>
|
||||
<TextInput
|
||||
id="cancelReason"
|
||||
labelText="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user