- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save. - Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send. - History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured. - Optional auto-push after issue runs in the background and never blocks or fails the issue. - Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string. The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
527 lines
22 KiB
TypeScript
527 lines
22 KiB
TypeScript
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 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<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 [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
|
|
const [syncs, setSyncs] = useState<Map<number, ErpnextSyncStatus>>(new Map());
|
|
const [selected, setSelected] = useState<Set<number>>(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<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 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 (
|
|
<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>
|
|
|
|
<div className="voiced-history__bulk" role="group" aria-label="Actions for the selected invoices">
|
|
<span aria-live="polite">{selected.size} selected</span>
|
|
<Button kind="tertiary" size="md" disabled={selected.size === 0 || blockedReason !== null} onClick={() => askSend(selectedRows)}>
|
|
Send selected to ERPNext
|
|
</Button>
|
|
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "json")}>
|
|
Export selected (voiced.invoice.v1 JSON)
|
|
</Button>
|
|
<Button kind="ghost" size="md" disabled={selected.size === 0 || generic.busy} onClick={() => void generic.run([...selected], "csv")}>
|
|
Export selected (line items CSV)
|
|
</Button>
|
|
{selected.size > 0 ? (
|
|
<Button kind="ghost" size="md" onClick={() => setSelected(new Set())}>
|
|
Clear selection
|
|
</Button>
|
|
) : null}
|
|
{blockedReason !== null && selected.size > 0 ? (
|
|
<span className="voiced-muted">
|
|
{blockedReason}{" "}
|
|
<Link
|
|
href="#"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
onOpenSettings();
|
|
}}
|
|
>
|
|
Open Settings
|
|
</Link>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{sorted.length === 0 ? (
|
|
<div className="voiced-empty">No invoices match these filters.</div>
|
|
) : (
|
|
<TableContainer>
|
|
<Table aria-label="Invoices">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableHeader>
|
|
<Checkbox id="historySelectPage" labelText="Select all invoices on this page" hideLabel checked={pageAllSelected} onChange={(_e, { checked }) => togglePage(checked)} />
|
|
</TableHeader>
|
|
{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>
|
|
))}
|
|
{showErpColumn ? <TableHeader>ERPNext</TableHeader> : null}
|
|
<TableHeader>
|
|
<span className="cds--assistive-text">Actions</span>
|
|
</TableHeader>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{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 (
|
|
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
|
|
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
<Checkbox
|
|
id={`historySelect-${row.id}`}
|
|
labelText={`Select invoice ${row.number}`}
|
|
hideLabel
|
|
checked={selected.has(row.id)}
|
|
onChange={(_e, { checked }) => toggleOne(row.id, checked)}
|
|
/>
|
|
</TableCell>
|
|
<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>
|
|
{showErpColumn ? (
|
|
<TableCell>
|
|
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
|
|
{erpTag.label}
|
|
</Tag>
|
|
</TableCell>
|
|
) : null}
|
|
<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")} />
|
|
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([row.id], "json")} />
|
|
{erpOn || syncs.size > 0 ? (
|
|
<OverflowMenuItem
|
|
itemText="Send to ERPNext"
|
|
hasDivider
|
|
disabled={rowBlocked !== null}
|
|
title={rowBlocked ?? undefined}
|
|
onClick={() => askSend([row])}
|
|
/>
|
|
) : null}
|
|
{canOpenInErpnext(sync) && erpOn ? <OverflowMenuItem itemText="Open in ERPNext" onClick={() => void onOpenErpnext(row.id)} /> : null}
|
|
</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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!erpOn && !loading && rows.length > 0 ? (
|
|
<p className="voiced-muted" style={{ marginTop: "1rem" }}>
|
|
ERPNext is not set up.{" "}
|
|
<Link
|
|
href="#"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
onOpenSettings();
|
|
}}
|
|
>
|
|
Set it up in Settings, Integrations
|
|
</Link>{" "}
|
|
to send invoices to it.
|
|
</p>
|
|
) : null}
|
|
|
|
<SendToErpnextModal
|
|
open={sendTargets !== null}
|
|
invoices={sendTargets?.invoices ?? []}
|
|
skipped={sendTargets?.skipped ?? 0}
|
|
defaultSubmit={erpCfg?.submitOnPush ?? false}
|
|
onClose={() => setSendTargets(null)}
|
|
onFinished={() => void loadErpnext()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|