Files
Voiced/src/views/InvoiceDetail.tsx
T
xavierk 3aa0d33a01 Add units of measure and fractional quantities to rate lines
Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.

- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
  characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
  allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
  same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
  priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
  custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
  Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
  and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
  push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:20 +05:30

748 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Link,
Modal,
OverflowMenu,
OverflowMenuItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
Tile,
} from "@carbon/react";
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
import { api, type ArchiveStatus } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import { toDraftPayload } from "../lib/draft";
import { invoiceToFormState } from "../lib/duplicate";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useGenericExport } from "../hooks/useGenericExport";
import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextPushResult, ErpnextSyncStatus } from "../lib/erpnext";
import {
canOpenInErpnext,
describePaymentPush,
describePush,
failedPush,
formatSyncedAt,
isConfigured,
paymentPushDisabledReason,
pushDisabledReason,
syncTag,
} from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
import { quantityText } from "../lib/units";
interface Props {
invoiceId: number;
settings: Settings;
onBack: () => void;
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
onDuplicated: (draftId: number) => void;
/** Goes to Settings, Integrations. */
onOpenSettings: () => void;
}
interface Frozen {
vendorName?: string;
vendorAddress?: string;
vendorGstin?: string;
vendorPan?: string;
vendorEmail?: string;
vendorPhone?: string;
}
function parseSnapshot(text: string): Frozen {
try {
return text ? (JSON.parse(text) as Frozen) : {};
} catch {
return {};
}
}
const stateName = (code: string) => {
const s = INDIAN_STATES.find((x) => x.code === code);
return s ? `${code} - ${s.name}` : code;
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="voiced-detail__field">
<dt className="voiced-muted">{label}</dt>
<dd>{children || "—"}</dd>
</div>
);
}
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated, onOpenSettings }: Props) {
const toast = useToast();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [payments, setPayments] = useState<Payment[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [pdfError, setPdfError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [recordOpen, setRecordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false);
const [erpCfg, setErpCfg] = useState<ErpnextConfig | null>(null);
const [erpSync, setErpSync] = useState<ErpnextSyncStatus | undefined>(undefined);
const [lastPush, setLastPush] = useState<ErpnextPushResult | null>(null);
const [sendingErp, setSendingErp] = useState(false);
const [sendingPayment, setSendingPayment] = useState<number | null>(null);
const generic = useGenericExport();
useReturnFocus(deleteTarget !== null);
useReturnFocus(cancelOpen);
// Guards against a slow load for a previous id landing after the id changed.
const generation = useRef(0);
const loadPdf = useCallback(async (id: number, gen: number) => {
setPdfError(null);
try {
const status = await api.archiveStatus(id);
if (gen !== generation.current) return;
setArchive(status);
if (status.archived) {
const bytes = await api.readArchive(id);
if (gen === generation.current) setPdf(bytes);
} else {
setPdf(null);
}
} catch (e) {
if (gen === generation.current) setPdfError(String(e));
}
}, []);
const loadData = useCallback(async (id: number, gen: number) => {
try {
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
if (gen !== generation.current) return;
setInvoice(inv);
setPayments(pays);
setLoadError(null);
} catch (e) {
if (gen === generation.current) setLoadError(String(e));
}
}, []);
/** ERPNext is optional: when it cannot be read the panel just stays out of the way. */
const loadErp = useCallback(async (id: number, gen: number) => {
try {
const [cfg, sync] = await Promise.all([api.erpnextGetConfig(), api.erpnextSyncStatus(id)]);
if (gen !== generation.current) return;
setErpCfg(cfg);
setErpSync(sync);
} catch {
if (gen === generation.current) {
setErpCfg(null);
setErpSync(undefined);
}
}
}, []);
useEffect(() => {
const gen = ++generation.current;
setInvoice(null);
setPdf(null);
setArchive(null);
setLastPush(null);
void loadData(invoiceId, gen);
void loadPdf(invoiceId, gen);
void loadErp(invoiceId, gen);
}, [invoiceId, loadData, loadPdf, loadErp]);
const refresh = () => loadData(invoiceId, generation.current);
const exp = useInvoiceExport({
settings,
bank: null,
// An export that had to archive the original first: show it in the preview.
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
});
const busy = exp.busy || archiving || duplicating;
const onExport = (mode: ExportMode, source: ExportSource) => {
if (!invoice) return;
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
};
const onArchive = async () => {
if (!invoice) return;
setArchiving(true);
try {
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
toast.success(`Invoice ${invoice.number} is archived`);
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
} else {
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
}
} catch (e) {
toast.error("Could not archive the PDF", String(e));
} finally {
setArchiving(false);
}
};
const onDuplicate = async () => {
if (!invoice) return;
setDuplicating(true);
try {
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
onDuplicated(id);
} catch (e) {
toast.error("Could not duplicate the invoice", String(e));
} finally {
setDuplicating(false);
}
};
const onCancelInvoice = async () => {
if (!invoice) return;
try {
await api.cancelInvoice(invoice.id, reason);
toast.success(`Invoice ${invoice.number} cancelled`);
setCancelOpen(false);
setReason("");
await refresh();
} catch (e) {
setCancelOpen(false);
toast.error("Could not cancel the invoice", String(e));
}
};
/** `submit` undefined keeps what the invoice already is in ERPNext. A failure is shown, never thrown. */
const onSendErp = async (submit: boolean) => {
if (!invoice) return;
setSendingErp(true);
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(invoice.id, submit);
} catch (e) {
result = failedPush(invoice.id, invoice.number, e);
}
setLastPush(result);
const note = describePush(result);
toast[note.kind](note.title, note.subtitle);
await loadErp(invoiceId, generation.current);
setSendingErp(false);
};
const onOpenErp = async () => {
try {
await openInErpnext(invoiceId);
} catch (e) {
toast.error("Could not open ERPNext", String(e));
}
};
const onSendPayment = async (paymentId: number) => {
setSendingPayment(paymentId);
try {
const note = describePaymentPush(await api.erpnextPushPayment(paymentId));
toast[note.kind](note.title, note.subtitle);
await refresh();
} catch (e) {
toast.error("Could not send the payment", String(e));
} finally {
setSendingPayment(null);
}
};
const onDeletePayment = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteTarget(null);
try {
await api.deletePayment(target.id);
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
await refresh();
} catch (e) {
toast.error("Could not delete the payment", String(e));
}
};
if (loadError) {
return (
<div className="voiced-page">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
</div>
);
}
if (!invoice) {
return (
<div className="voiced-page">
<InlineLoading description="Loading invoice" />
</div>
);
}
const badge = statusBadge(invoice.status, invoice.paymentStatus);
const vendor = parseSnapshot(invoice.vendorSnapshot);
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
const balance = invoice.balancePaise ?? 0;
const canPay = invoice.status === "issued" && balance > 0;
const half = invoice.taxRate / 2;
const erpOn = isConfigured(erpCfg);
const erpTag = syncTag(erpSync);
const erpBlocked = pushDisabledReason({ cfg: erpCfg, vendorRegistered: settings.gstRegistration !== "unregistered", invoiceStatus: invoice.status });
const erpHasState = Boolean(erpSync && erpSync.status !== "none");
const erpSubmitted = erpSync?.status === "synced" && erpSync.remoteDocstatus === 1;
const showPaymentErp = erpOn || payments.some((p) => p.erpnextPaymentEntry);
return (
<div className="voiced-page voiced-detail">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<div className="voiced-detail__head">
<h1 className="voiced-page__title">{invoice.number}</h1>
<Tag type={badge.type}>{badge.label}</Tag>
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</Tag>
) : null}
{archive && !archive.archived ? (
<Tag type="warm-gray" size="sm">
Not archived
</Tag>
) : null}
</div>
<p className="voiced-page__subtitle">
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
</p>
{invoice.status === "cancelled" ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="This invoice is cancelled"
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
/>
) : null}
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
{canPay ? (
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
Record payment
</Button>
) : null}
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
Export PDF
</Button>
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
Export flattened
</Button>
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
{duplicating ? "Duplicating…" : "Duplicate"}
</Button>
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
<OverflowMenuItem itemText="Export as voiced.invoice.v1 (JSON)" hasDivider onClick={() => void generic.run([invoice.id], "json")} />
<OverflowMenuItem itemText="Export line items (CSV)" onClick={() => void generic.run([invoice.id], "csv")} />
{invoice.status === "issued" ? (
<OverflowMenuItem
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
hasDivider
isDelete
disabled={payments.length > 0}
onClick={() => {
setReason("");
setCancelOpen(true);
}}
/>
) : null}
</OverflowMenu>
</div>
<ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack">
<div className="voiced-detail__tiles">
<Tile>
<p className="voiced-muted">Total</p>
<p className="voiced-detail__amount">{formatPaise(total)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Balance</p>
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
</Tile>
</div>
<section aria-labelledby="detail-parties">
<h2 id="detail-parties" className="voiced-detail__h2">
Parties
</h2>
<div className="voiced-detail__parties">
<dl>
<Field label="Billed to">{invoice.clientName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
</Field>
<Field label="GSTIN">{invoice.clientGstin}</Field>
<Field label="PO number">{invoice.poNumber}</Field>
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
</dl>
<dl>
<Field label="Issued by">{vendor.vendorName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
</Field>
<Field label="GSTIN">{vendor.vendorGstin}</Field>
<Field label="PAN">{vendor.vendorPan}</Field>
</dl>
</div>
</section>
<section aria-labelledby="detail-items">
<h2 id="detail-items" className="voiced-detail__h2">
Items
</h2>
<TableContainer>
<Table aria-label="Invoice items" size="sm">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader className="voiced-num">Amount</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{invoice.items.map((item, i) => (
<TableRow key={item.id ?? i}>
<TableCell>
<span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? (
<div className="voiced-muted">
{quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
</div>
) : null}
</TableCell>
<TableCell>{item.hsnSac || "—"}</TableCell>
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<dl className="voiced-detail__totals">
<div>
<dt>Subtotal</dt>
<dd>₹{formatAmount(invoice.subtotal)}</dd>
</div>
{invoice.discount > 0 ? (
<div>
<dt>Discount</dt>
<dd>−₹{formatAmount(invoice.discount)}</dd>
</div>
) : null}
{invoice.taxType === "cgst_sgst" ? (
<>
<div>
<dt>CGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
</div>
<div>
<dt>SGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
</div>
</>
) : null}
{invoice.taxType === "igst" ? (
<div>
<dt>IGST @ {invoice.taxRate}%</dt>
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
</div>
) : null}
<div className="voiced-detail__grand">
<dt>Total</dt>
<dd>₹{formatAmount(invoice.total)}</dd>
</div>
</dl>
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
</section>
<section aria-labelledby="detail-erpnext">
<h2 id="detail-erpnext" className="voiced-detail__h2">
ERPNext
</h2>
{!erpOn && !erpHasState ? (
<p className="voiced-muted">
ERPNext is not set up.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Set it up in Settings, Integrations
</Link>
.
</p>
) : (
<div className="voiced-erp__panel">
<dl>
<Field label="Status">
<Tag type={erpTag.type} size="sm" title={erpTag.tooltip}>
{erpTag.label}
</Tag>
</Field>
{erpSync?.remoteName ? <Field label="ERPNext document">{erpSync.remoteName}</Field> : null}
{erpSync?.syncedAt ? <Field label="Last synced">{formatSyncedAt(erpSync.syncedAt)}</Field> : null}
{erpSync?.status === "synced" ? <Field label="PDF">{erpSync.attached ? "Attached in ERPNext" : "Not attached"}</Field> : null}
</dl>
{erpSync && (erpSync.status === "error" || erpSync.status === "conflict") && erpSync.lastError ? (
<InlineNotification
kind={erpSync.status === "conflict" ? "warning" : "error"}
lowContrast
hideCloseButton
title={erpSync.status === "conflict" ? "Conflict with ERPNext" : "The last send failed"}
subtitle={erpSync.lastError}
/>
) : null}
{lastPush && lastPush.warnings.length > 0 ? (
<InlineNotification kind="warning" lowContrast hideCloseButton title="Sent with notes" subtitle={lastPush.warnings.join(" ")} />
) : null}
{erpOn ? (
<>
<div className="voiced-header-action">
{erpSubmitted ? (
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
{sendingErp ? "Sending…" : "Re-send"}
</Button>
) : (
<>
<Button kind="tertiary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(false)}>
{sendingErp ? "Sending…" : erpHasState ? "Re-send as draft" : "Send as draft"}
</Button>
<Button kind="primary" size="sm" disabled={sendingErp || erpBlocked !== null} onClick={() => void onSendErp(true)}>
Send &amp; submit
</Button>
</>
)}
{canOpenInErpnext(erpSync) ? (
<Button kind="ghost" size="sm" onClick={() => void onOpenErp()}>
Open in ERPNext
</Button>
) : null}
</div>
{erpBlocked ? (
<p className="voiced-muted">
{erpBlocked}{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
) : null}
</>
) : (
<p className="voiced-muted">
ERPNext is not set up any more, so nothing can be sent.{" "}
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
</Link>
</p>
)}
</div>
)}
</section>
<section aria-labelledby="detail-payments">
<h2 id="detail-payments" className="voiced-detail__h2">
Payments
</h2>
{payments.length === 0 ? (
<p className="voiced-muted">No payments recorded.</p>
) : (
<TableContainer>
<Table aria-label="Payments" size="sm">
<TableHead>
<TableRow>
<TableHeader>Date</TableHeader>
<TableHeader>Mode</TableHeader>
<TableHeader>Reference</TableHeader>
<TableHeader className="voiced-num">Received</TableHeader>
<TableHeader className="voiced-num">TDS</TableHeader>
{showPaymentErp ? <TableHeader>ERPNext</TableHeader> : null}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell>{formatDate(p.paidOn)}</TableCell>
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
<TableCell>
{p.reference || "—"}
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
</TableCell>
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
{showPaymentErp ? (
<TableCell>
{p.erpnextPaymentEntry ? (
<>
<Tag type="green" size="sm">
Sent
</Tag>{" "}
<span className="voiced-muted">{p.erpnextPaymentEntry}</span>
</>
) : (
(() => {
const reason = paymentPushDisabledReason({ cfg: erpCfg, sync: erpSync, entry: p.erpnextPaymentEntry });
return (
<>
<Button
kind="ghost"
size="sm"
disabled={reason !== null || sendingPayment !== null}
onClick={() => void onSendPayment(p.id)}
>
{sendingPayment === p.id ? "Sending…" : "Send payment to ERPNext"}
</Button>
{reason ? <div className="voiced-muted">{reason}</div> : null}
</>
);
})()
)}
</TableCell>
) : null}
<TableCell>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
tooltipPosition="left"
onClick={() => setDeleteTarget(p)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</section>
</div>
<aside className="voiced-detail__preview" aria-label="Archived PDF">
<h2 className="voiced-detail__h2">PDF as issued</h2>
{archive && !archive.archived ? (
<div className="voiced-detail__unarchived">
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
{archiving ? "Working…" : "Render & archive"}
</Button>
</div>
) : (
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
)}
</aside>
</div>
<RecordPaymentModal
open={recordOpen}
invoiceId={invoice.id}
invoiceNumber={invoice.number}
balancePaise={balance}
onClose={() => setRecordOpen(false)}
onRecorded={(p) => {
setRecordOpen(false);
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
void refresh();
}}
/>
<Modal
open={deleteTarget !== null}
danger
modalHeading="Delete this payment?"
primaryButtonText="Delete payment"
secondaryButtonText="Keep payment"
onRequestClose={() => setDeleteTarget(null)}
onRequestSubmit={() => void onDeletePayment()}
>
<p>
{deleteTarget
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.${deleteTarget.erpnextPaymentEntry ? ` It was sent to ERPNext as ${deleteTarget.erpnextPaymentEntry}; that entry is not removed there.` : ""}`
: ""}
</p>
</Modal>
<Modal
open={cancelOpen}
danger
modalHeading={`Cancel invoice ${invoice.number}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelOpen(false)}
onRequestSubmit={() => void onCancelInvoice()}
>
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
</Modal>
</div>
);
}