feat: implement invoice formatting and document loading hooks

This commit is contained in:
2026-10-21 12:55:29 +05:30
parent 5f8bc70174
commit e4061fa94c
3 changed files with 364 additions and 0 deletions
@@ -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' ? <A4Sheet {...props} /> : <ReceiptSheet {...props} paper={paper} />
}
// ─── A4 ───────────────────────────────────────────────────────────────────────
function A4Sheet({
invoice,
payments = [],
customer,
renderQuantity,
renderUnitPrice,
renderActions,
footerRow,
showAgentEdits,
className,
}: Omit<InvoiceTemplateProps, 'paper'>) {
const { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice)
const hasRemoved = invoice.items.some((i) => i.isRemoved)
return (
<div
className={cn(
'invoice-sheet relative mx-auto w-full max-w-[210mm] rounded-lg border bg-white p-10 text-sm leading-relaxed text-neutral-900 shadow-sm',
'print:max-w-none print:rounded-none print:border-0 print:p-[14mm] print:shadow-none',
className,
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-6 border-b pb-5" style={{ borderColor: RULE }}>
<h2 className="text-4xl font-bold tracking-wide">INVOICE</h2>
<dl className="grid grid-cols-[auto_auto] gap-x-6 gap-y-1 text-right text-sm">
<dt className="text-neutral-500">Invoice No.</dt>
<dd className="font-mono font-semibold">{invoiceNo(invoice.id)}</dd>
<dt className="text-neutral-500">Invoice Date</dt>
<dd>{fmtDate(invoice.createdAt)}</dd>
<dt className="text-neutral-500">PO Ref.</dt>
<dd className="font-mono">{invoice.shopPOId.substring(0, 8).toUpperCase()}</dd>
{status && (
<>
<dt className="text-neutral-500">Status</dt>
<dd className="font-semibold" style={{ color: HEADING }}>{status}</dd>
</>
)}
</dl>
</div>
{/* Bill to */}
<div className="mt-6">
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Bill To
</h3>
<p className="text-base font-semibold">{invoice.storeName}</p>
{customer?.address && <p className="text-neutral-600">{customer.address}</p>}
{customer?.phone && <p className="text-neutral-600">{customer.phone}</p>}
</div>
{/* Items */}
<div className="mt-6">
<h3 className="mb-2 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Item Details
</h3>
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
<th className="px-2 py-2 text-left font-medium">Item</th>
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
</tr>
</thead>
<tbody>
{invoice.items.length === 0 && !footerRow ? (
<tr>
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
</tr>
) : (
invoice.items.map((item) => (
<tr
key={item.id}
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
>
<td className="px-2 py-2.5">{item.itemName}</td>
<td className="px-2 py-2.5 text-right">
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
</td>
<td className="px-2 py-2.5 text-right">
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
</td>
<td className="px-2 py-2.5 text-right font-medium">{fmtMoney(item.quantity * item.unitPrice)}</td>
{renderActions && <td className="px-2 py-2.5 text-right no-underline print:hidden">{renderActions(item)}</td>}
</tr>
))
)}
{footerRow}
</tbody>
</table>
{hasRemoved && (
<p className="mt-2 text-xs text-neutral-500">Struck-through lines were removed and are not charged.</p>
)}
</div>
{/* Notes + totals */}
<div className="mt-6 grid grid-cols-[1fr_auto] gap-10">
<div>
{showAgentEdits && (
<>
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Notes / Agent Edits
</h3>
{[0, 1, 2, 3].map((n) => (
<div key={n} className="h-7 border-b border-dotted border-neutral-400" />
))}
</>
)}
</div>
<dl className="w-72 break-inside-avoid text-sm">
<div className="flex justify-between border-b py-1.5">
<dt className="text-neutral-500">Subtotal</dt>
<dd>{fmtMoney(total)}</dd>
</div>
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
<dt>Total</dt>
<dd>{fmtMoney(total)}</dd>
</div>
{paid > 0 && (
<>
<div className="flex justify-between py-1.5">
<dt className="text-neutral-500">Paid</dt>
<dd>{fmtMoney(paid)}</dd>
</div>
<div className="flex justify-between border-t py-1.5 font-semibold">
<dt>Balance Due</dt>
<dd>{fmtMoney(balance)}</dd>
</div>
</>
)}
</dl>
</div>
{/* Payments */}
<div className="mt-6 break-inside-avoid">
<h3 className="mb-1 border-b pb-1 text-xs font-semibold uppercase tracking-widest" style={{ borderColor: RULE, color: HEADING }}>
Payments
</h3>
{payments.length === 0 ? (
<p className="text-neutral-500">No payment recorded yet.</p>
) : (
<table className="w-full text-sm">
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b border-neutral-100">
<td className="w-28 py-1">{fmtDate(p.collectedAt)}</td>
<td className="py-1">
{p.method}
{p.chequeNumber && <span className="text-neutral-500"> · Cheque {p.chequeNumber}</span>}
</td>
<td className="py-1 text-right font-medium">{fmtMoney(p.amount)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Signatures */}
<div className="mt-14 grid grid-cols-2 gap-16 text-xs text-neutral-600">
<div className="border-t border-neutral-400 pt-1">Authorised by</div>
<div className="border-t border-neutral-400 pt-1">Received by (customer signature)</div>
</div>
</div>
)
}
// ─── 80mm / 58mm thermal receipt ──────────────────────────────────────────────
function ReceiptSheet({
invoice,
payments = [],
customer,
paper,
showAgentEdits,
className,
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
const { total, paid, balance } = totals(invoice, payments)
const status = statusLabel(invoice)
const narrow = paper === '58mm'
const rule = <div className="my-2 border-t border-dashed border-black" />
const num = (n: number) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return (
<div
className={cn(
'invoice-sheet mx-auto bg-white font-mono text-black shadow-sm print:shadow-none',
narrow ? 'w-[58mm] px-[4mm] py-[2.5mm] text-[9.5px]' : 'w-[80mm] px-[5mm] py-[4mm] text-[11px]',
className,
)}
>
<div className="text-center">
<p className="text-base font-bold tracking-widest">INVOICE</p>
{status && <p className="font-bold">*** {status} ***</p>}
</div>
{rule}
<div className="space-y-0.5">
<p>No: {invoiceNo(invoice.id)}</p>
<p>Date: {fmtDate(invoice.createdAt)}</p>
<p>Customer: {invoice.storeName}</p>
{customer?.phone && <p>Phone: {customer.phone}</p>}
</div>
{rule}
<div className="space-y-1.5">
{invoice.items.map((i) => (
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
<p>{i.itemName}</p>
<p className="flex justify-between">
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
<span>{num(i.quantity * i.unitPrice)}</span>
</p>
</div>
))}
</div>
{rule}
<p className="flex justify-between">
<span>Subtotal</span>
<span>{fmtMoney(total)}</span>
</p>
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
<p className="flex justify-between">
<span>TOTAL</span>
<span>{fmtMoney(total)}</span>
</p>
</div>
{paid > 0 && (
<>
<p className="flex justify-between"><span>Paid</span><span>{fmtMoney(paid)}</span></p>
<p className="flex justify-between font-bold"><span>Balance</span><span>{fmtMoney(balance)}</span></p>
</>
)}
{payments.length > 0 && (
<>
{rule}
{payments.map((p) => (
<p key={p.id} className="flex justify-between">
<span>{fmtDate(p.collectedAt)} {p.method}</span>
<span>{num(p.amount)}</span>
</p>
))}
</>
)}
{showAgentEdits && (
<>
{rule}
<p>Notes:</p>
{[0, 1, 2].map((n) => <div key={n} className="h-5 border-b border-dotted border-black" />)}
</>
)}
{rule}
<p className="text-center">Thank you!</p>
</div>
)
}