feat: implement invoice formatting and document loading hooks
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user