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 (
{label}
{children || "—"}
);
}
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated, onOpenSettings }: Props) {
const toast = useToast();
const [invoice, setInvoice] = useState(null);
const [payments, setPayments] = useState([]);
const [loadError, setLoadError] = useState(null);
const [archive, setArchive] = useState(null);
const [pdf, setPdf] = useState(null);
const [pdfError, setPdfError] = useState(null);
const [archiving, setArchiving] = useState(false);
const [recordOpen, setRecordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false);
const [erpCfg, setErpCfg] = useState(null);
const [erpSync, setErpSync] = useState(undefined);
const [lastPush, setLastPush] = useState(null);
const [sendingErp, setSendingErp] = useState(false);
const [sendingPayment, setSendingPayment] = useState(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 (
Back to invoices
);
}
if (!invoice) {
return (
);
}
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 (
Back to invoices
{invoice.number}
{badge.label}
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
Partly paid
) : null}
{archive && !archive.archived ? (
Not archived
) : null}
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
{invoice.status === "cancelled" ? (
) : null}
{canPay ? (
setRecordOpen(true)} disabled={busy}>
Record payment
) : null}
onExport("searchable", "original")} disabled={busy}>
Export PDF
onExport("flattened", "original")} disabled={busy}>
Export flattened
void onDuplicate()} disabled={busy}>
{duplicating ? "Duplicating…" : "Duplicate"}
onExport("searchable", "rerender")} />
void generic.run([invoice.id], "json")} />
void generic.run([invoice.id], "csv")} />
{invoice.status === "issued" ? (
0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
hasDivider
isDelete
disabled={payments.length > 0}
onClick={() => {
setReason("");
setCancelOpen(true);
}}
/>
) : null}
The export result could not be shown ({e.message}). The PDF was still saved.
}>
Total
{formatPaise(total)}
Received
{formatPaise(invoice.paidPaise ?? 0)}
TDS deducted
{formatPaise(invoice.tdsPaise ?? 0)}
Balance
{formatPaise(balance)}
Parties
{invoice.clientName}
{invoice.clientAddress}
{invoice.clientGstin}
{invoice.poNumber}
{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}
{vendor.vendorName}
{vendor.vendorAddress}
{vendor.vendorGstin}
{vendor.vendorPan}
Items
Description
HSN/SAC
Amount
{invoice.items.map((item, i) => (
{item.description}
{item.mode === "rate" ? (
{quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
) : null}
{item.hsnSac || "—"}
₹{formatAmount(item.amount)}
))}
Subtotal
₹{formatAmount(invoice.subtotal)}
{invoice.discount > 0 ? (
Discount
−₹{formatAmount(invoice.discount)}
) : null}
{invoice.taxType === "cgst_sgst" ? (
<>
CGST @ {half}%
₹{formatAmount(invoice.cgstAmount)}
SGST @ {half}%
₹{formatAmount(invoice.sgstAmount)}
>
) : null}
{invoice.taxType === "igst" ? (
IGST @ {invoice.taxRate}%
₹{formatAmount(invoice.igstAmount)}
) : null}
Total
₹{formatAmount(invoice.total)}
{invoice.amountInWords ? {invoice.amountInWords}
: null}
{invoice.notes ? {invoice.notes}
: null}
ERPNext
{!erpOn && !erpHasState ? (
ERPNext is not set up.{" "}
{
e.preventDefault();
onOpenSettings();
}}
>
Set it up in Settings, Integrations
.
) : (
{erpTag.label}
{erpSync?.remoteName ? {erpSync.remoteName} : null}
{erpSync?.syncedAt ? {formatSyncedAt(erpSync.syncedAt)} : null}
{erpSync?.status === "synced" ? {erpSync.attached ? "Attached in ERPNext" : "Not attached"} : null}
{erpSync && (erpSync.status === "error" || erpSync.status === "conflict") && erpSync.lastError ? (
) : null}
{lastPush && lastPush.warnings.length > 0 ? (
) : null}
{erpOn ? (
<>
{erpSubmitted ? (
void onSendErp(true)}>
{sendingErp ? "Sending…" : "Re-send"}
) : (
<>
void onSendErp(false)}>
{sendingErp ? "Sending…" : erpHasState ? "Re-send as draft" : "Send as draft"}
void onSendErp(true)}>
Send & submit
>
)}
{canOpenInErpnext(erpSync) ? (
void onOpenErp()}>
Open in ERPNext
) : null}
{erpBlocked ? (
{erpBlocked}{" "}
{
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
) : null}
>
) : (
ERPNext is not set up any more, so nothing can be sent.{" "}
{
e.preventDefault();
onOpenSettings();
}}
>
Open Settings
)}
)}
Payments
{payments.length === 0 ? (
No payments recorded.
) : (
Date
Mode
Reference
Received
TDS
{showPaymentErp ? ERPNext : null}
Actions
{payments.map((p) => (
{formatDate(p.paidOn)}
{paymentModeLabel(p.mode)}
{p.reference || "—"}
{p.notes ? {p.notes}
: null}
{formatPaise(p.amountPaise)}
{formatPaise(p.tdsPaise)}
{showPaymentErp ? (
{p.erpnextPaymentEntry ? (
<>
Sent
{" "}
{p.erpnextPaymentEntry}
>
) : (
(() => {
const reason = paymentPushDisabledReason({ cfg: erpCfg, sync: erpSync, entry: p.erpnextPaymentEntry });
return (
<>
void onSendPayment(p.id)}
>
{sendingPayment === p.id ? "Sending…" : "Send payment to ERPNext"}
{reason ? {reason}
: null}
>
);
})()
)}
) : null}
setDeleteTarget(p)}
/>
))}
)}
setRecordOpen(false)}
onRecorded={(p) => {
setRecordOpen(false);
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
void refresh();
}}
/>
setDeleteTarget(null)}
onRequestSubmit={() => void onDeletePayment()}
>
{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.` : ""}`
: ""}
setCancelOpen(false)}
onRequestSubmit={() => void onCancelInvoice()}
>
The invoice stays in the list as cancelled and its number is not reused.
setReason(e.target.value)} />
);
}