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
748 lines
29 KiB
TypeScript
748 lines
29 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|