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 (
); } 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 (

{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 ? ( ) : null} 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 ? ( ) : ( <> )} {canOpenInErpnext(erpSync) ? ( ) : 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 ( <> {reason ?
{reason}
: null} ); })() )}
) : null}
)}
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)} />
); }