From e4061fa94c4320e5a0dc4e9a18c910d99f897763 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Wed, 21 Oct 2026 12:55:29 +0530 Subject: [PATCH] feat: implement invoice formatting and document loading hooks --- .../components/invoice/invoice-format.ts | 19 ++ .../components/invoice/invoice-template.tsx | 304 ++++++++++++++++++ .../invoice/use-invoice-document.ts | 41 +++ 3 files changed, 364 insertions(+) create mode 100644 distribution_fe/components/invoice/invoice-format.ts create mode 100644 distribution_fe/components/invoice/invoice-template.tsx create mode 100644 distribution_fe/components/invoice/use-invoice-document.ts diff --git a/distribution_fe/components/invoice/invoice-format.ts b/distribution_fe/components/invoice/invoice-format.ts new file mode 100644 index 0000000..4d20baa --- /dev/null +++ b/distribution_fe/components/invoice/invoice-format.ts @@ -0,0 +1,19 @@ +// Shared number/date formatting for printed invoices. + +/** `Rs. 12,450.00` — thousands separator, exactly 2 decimals. */ +export const fmtMoney = (n: number) => + `Rs. ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +/** Quantity: up to 3 decimals, trailing zeros dropped. */ +export const fmtQty = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 3 }) + +/** `dd/MM/yyyy` */ +export function fmtDate(v: string | null | undefined) { + if (!v) return '—' + const d = new Date(v) + const dd = String(d.getDate()).padStart(2, '0') + const mm = String(d.getMonth() + 1).padStart(2, '0') + return `${dd}/${mm}/${d.getFullYear()}` +} + +export const invoiceNo = (id: string) => `INV-${id.substring(0, 8).toUpperCase()}` diff --git a/distribution_fe/components/invoice/invoice-template.tsx b/distribution_fe/components/invoice/invoice-template.tsx new file mode 100644 index 0000000..ad7e9a5 --- /dev/null +++ b/distribution_fe/components/invoice/invoice-template.tsx @@ -0,0 +1,304 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' +import type { InvoiceItemResponse, InvoiceResponse, PaymentResponse } from '@/types/api' +import { fmtDate, fmtMoney, fmtQty, invoiceNo } from './invoice-format' + +export type InvoicePaper = 'A4' | '80mm' | '58mm' + +export interface InvoiceCustomer { + address?: string | null + phone?: string | null +} + +interface InvoiceTemplateProps { + invoice: InvoiceResponse + payments?: PaymentResponse[] + customer?: InvoiceCustomer + paper?: InvoicePaper + // Draft-editor hooks (A4 only): inputs in the qty / price cells, a per-row action, and an extra table row. + renderQuantity?: (item: InvoiceItemResponse) => ReactNode + renderUnitPrice?: (item: InvoiceItemResponse) => ReactNode + renderActions?: (item: InvoiceItemResponse) => ReactNode + footerRow?: ReactNode + // Blank lines for the agent's handwritten edits on the paper copy. + showAgentEdits?: boolean + className?: string +} + +// Soft slate palette: light rules, muted section headings. +const RULE = '#cbd5e1' +const HEADING = '#64748b' + +const statusLabel = (invoice: InvoiceResponse) => + invoice.status === 'Finalized' ? null : invoice.status.toUpperCase() + +// Removed lines stay on the document (struck through) but are excluded from every total. +function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) { + const total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) + const paid = payments.reduce((s, p) => s + p.amount, 0) + return { total, paid, balance: total - paid } +} + +export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) { + return paper === 'A4' ? : +} + +// ─── A4 ─────────────────────────────────────────────────────────────────────── + +function A4Sheet({ + invoice, + payments = [], + customer, + renderQuantity, + renderUnitPrice, + renderActions, + footerRow, + showAgentEdits, + className, +}: Omit) { + const { total, paid, balance } = totals(invoice, payments) + const status = statusLabel(invoice) + const hasRemoved = invoice.items.some((i) => i.isRemoved) + + return ( +
+ {/* Header */} +
+

INVOICE

+
+
Invoice No.
+
{invoiceNo(invoice.id)}
+
Invoice Date
+
{fmtDate(invoice.createdAt)}
+
PO Ref.
+
{invoice.shopPOId.substring(0, 8).toUpperCase()}
+ {status && ( + <> +
Status
+
{status}
+ + )} +
+
+ + {/* Bill to */} +
+

+ Bill To +

+

{invoice.storeName}

+ {customer?.address &&

{customer.address}

} + {customer?.phone &&

{customer.phone}

} +
+ + {/* Items */} +
+

+ Item Details +

+ + + + + + + + {renderActions && + + + {invoice.items.length === 0 && !footerRow ? ( + + + + ) : ( + invoice.items.map((item) => ( + + + + + + {renderActions && } + + )) + )} + {footerRow} + +
ItemQtyUnit PriceAmount} +
No items.
{item.itemName} + {renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)} + + {renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)} + {fmtMoney(item.quantity * item.unitPrice)}{renderActions(item)}
+ {hasRemoved && ( +

Struck-through lines were removed and are not charged.

+ )} +
+ + {/* Notes + totals */} +
+
+ {showAgentEdits && ( + <> +

+ Notes / Agent Edits +

+ {[0, 1, 2, 3].map((n) => ( +
+ ))} + + )} +
+
+
+
Subtotal
+
{fmtMoney(total)}
+
+
+
Total
+
{fmtMoney(total)}
+
+ {paid > 0 && ( + <> +
+
Paid
+
{fmtMoney(paid)}
+
+
+
Balance Due
+
{fmtMoney(balance)}
+
+ + )} +
+
+ + {/* Payments */} +
+

+ Payments +

+ {payments.length === 0 ? ( +

No payment recorded yet.

+ ) : ( + + + {payments.map((p) => ( + + + + + + ))} + +
{fmtDate(p.collectedAt)} + {p.method} + {p.chequeNumber && · Cheque {p.chequeNumber}} + {fmtMoney(p.amount)}
+ )} +
+ + {/* Signatures */} +
+
Authorised by
+
Received by (customer signature)
+
+
+ ) +} + +// ─── 80mm / 58mm thermal receipt ────────────────────────────────────────────── + +function ReceiptSheet({ + invoice, + payments = [], + customer, + paper, + showAgentEdits, + className, +}: Omit & { paper: Exclude }) { + const { total, paid, balance } = totals(invoice, payments) + const status = statusLabel(invoice) + const narrow = paper === '58mm' + const rule =
+ const num = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + + return ( +
+
+

INVOICE

+ {status &&

*** {status} ***

} +
+ {rule} +
+

No: {invoiceNo(invoice.id)}

+

Date: {fmtDate(invoice.createdAt)}

+

Customer: {invoice.storeName}

+ {customer?.phone &&

Phone: {customer.phone}

} +
+ {rule} +
+ {invoice.items.map((i) => ( +
+

{i.itemName}

+

+ {fmtQty(i.quantity)} x {num(i.unitPrice)} + {num(i.quantity * i.unitPrice)} +

+
+ ))} +
+ {rule} +

+ Subtotal + {fmtMoney(total)} +

+
+

+ TOTAL + {fmtMoney(total)} +

+
+ {paid > 0 && ( + <> +

Paid{fmtMoney(paid)}

+

Balance{fmtMoney(balance)}

+ + )} + {payments.length > 0 && ( + <> + {rule} + {payments.map((p) => ( +

+ {fmtDate(p.collectedAt)} {p.method} + {num(p.amount)} +

+ ))} + + )} + {showAgentEdits && ( + <> + {rule} +

Notes:

+ {[0, 1, 2].map((n) =>
)} + + )} + {rule} +

Thank you!

+
+ ) +} diff --git a/distribution_fe/components/invoice/use-invoice-document.ts b/distribution_fe/components/invoice/use-invoice-document.ts new file mode 100644 index 0000000..c2b01e4 --- /dev/null +++ b/distribution_fe/components/invoice/use-invoice-document.ts @@ -0,0 +1,41 @@ +'use client' + +import { useEffect, useState } from 'react' +import { invoices as invoicesApi, payments as paymentsApi, shopPos as shopPosApi, getApiError } from '@/lib/api/index' +import type { PaymentResponse, InvoiceResponse } from '@/types/api' +import type { InvoiceCustomer } from './invoice-template' + +// Loads everything the printed invoice needs. Only the invoice is required — if payments or the +// customer details fail to load, that block is simply left out. +export function useInvoiceDocument(invoiceId: string | null | undefined, enabled = true) { + const [invoice, setInvoice] = useState(null) + const [payments, setPayments] = useState([]) + const [customer, setCustomer] = useState() + const [error, setError] = useState(null) + + useEffect(() => { + if (!enabled || !invoiceId) return + let cancelled = false + setInvoice(null) + setPayments([]) + setCustomer(undefined) + setError(null) + + invoicesApi.get(invoiceId) + .then((inv) => { + if (cancelled) return + setInvoice(inv) + paymentsApi.listByInvoice(inv.id).then((p) => !cancelled && setPayments(p)).catch(() => {}) + shopPosApi.get(inv.shopPOId) + .then((po) => !cancelled && setCustomer({ address: po.storeAddress, phone: po.storeContactNumber })) + .catch(() => {}) + }) + .catch((err) => !cancelled && setError(getApiError(err))) + + return () => { + cancelled = true + } + }, [invoiceId, enabled]) + + return { invoice, payments, customer, error } +}