fixing recipts in sales

This commit is contained in:
2026-09-24 17:02:33 +05:30
parent 9185d39c39
commit d24072e20a
10 changed files with 697 additions and 258 deletions
@@ -0,0 +1,513 @@
"use client"
import { useEffect, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { CompanyProfile } from "@/types/company-profile"
import { Customer } from "@/types/customers"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
const ACCENT = "#8b1c24"
type PaperSize = "A4" | "80mm"
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
]
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
const PAGE_RULES: Record<PaperSize, string> = {
A4: "@page { size: A4; margin: 12mm; }",
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
}
function money(n: number) {
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function qty(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
/** dd/MM/yyyy, as on the reference invoice. */
function formatDate(value: string | Date) {
const d = new Date(value)
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
}
/** Everything both layouts render, resolved once. */
interface InvoiceDoc {
invoice: SalesInvoice
customer: Customer | null
payments: SalesInvoicePayment[]
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
dueDate: Date
notes: string[]
showLogo: boolean
showBank: boolean
}
const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
/** Section heading with the short accent underline. */
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="mb-3">
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
</div>
)
}
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
const t = invoice.totals
return (
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
<div
className="invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
style={printColorExact}
>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="text-5xl font-extrabold tracking-wide text-neutral-900">INVOICE</h1>
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
<dt className="text-xs font-bold text-neutral-900">Invoice No.</dt>
<dd>{invoice.invoiceNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
<dd>{formatDate(invoice.invoiceDate)}</dd>
<dt className="text-xs font-bold text-neutral-900">Due Date</dt>
<dd>{formatDate(dueDate)}</dd>
{invoice.status !== "Posted" && (
<>
<dt className="text-xs font-bold text-neutral-900">Status</dt>
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.status.toUpperCase()}</dd>
</>
)}
</dl>
</div>
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
)}
</div>
{/* Header rule: heavy dark bar over a thin accent line */}
<div className="mt-5 mb-7">
<div className="h-1 bg-neutral-900" />
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
</div>
{/* From / Bill To */}
<div className="grid grid-cols-2 gap-10">
<div>
<SectionTitle>From</SectionTitle>
{company ? (
<div className="leading-snug">
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
<div className="mt-2">
{company.phone && <p>{company.phone}</p>}
{company.email && <p>{company.email}</p>}
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
</div>
</div>
) : (
<p className="text-neutral-500 print:hidden">Company details aren&apos;t set up yet (Settings → Company Profile).</p>
)}
</div>
<div>
<SectionTitle>Bill To</SectionTitle>
<div className="leading-snug">
<p className="font-bold text-neutral-900">{invoice.customerSnapshotName}</p>
{customer && (customer.addressLine1 || customer.city) && (
<>
<p>{[customer.addressLine1, customer.addressLine2].filter(Boolean).join(", ")}</p>
<p>{[customer.city, customer.country].filter(Boolean).join(", ")}</p>
</>
)}
<div className="mt-2">
{customer?.phone && <p>{customer.phone}</p>}
{customer?.email && <p>{customer.email}</p>}
{invoice.customerSnapshotTaxNo && <p>Tax No: {invoice.customerSnapshotTaxNo}</p>}
</div>
</div>
</div>
</div>
{/* Line items */}
<div className="mt-8">
<SectionTitle>Item Details</SectionTitle>
<table className="w-full border-collapse">
<thead>
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
<th className="px-2 py-2.5 text-left">Item</th>
<th className="px-2 py-2.5 text-center">Qty</th>
<th className="px-2 py-2.5 text-right">Unit Price</th>
<th className="px-2 py-2.5 text-right">Discount</th>
<th className="px-2 py-2.5 text-right">Tax</th>
<th className="px-2 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody>
{invoice.lines.map((line) => (
<tr key={line.salesInvoiceLineId} className="border-b border-neutral-300">
<td className="px-2 py-2.5">{line.description}{line.isFreeIssue && <span className="ml-1 text-xs text-neutral-500">(free issue)</span>}</td>
<td className="px-2 py-2.5 text-center tabular-nums">
{qty(line.qty)}
{line.freeQty > 0 && <span className="block text-xs text-neutral-500">+ {qty(line.freeQty)} free</span>}
</td>
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitPrice)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountAmount > 0 ? `-${money(line.discountAmount)}` : "—"}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.taxAmount > 0 ? money(line.taxAmount) : "—"}</td>
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Notes / totals */}
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
<div>
{notes.length > 0 && (
<>
<SectionTitle>Notes / Terms</SectionTitle>
<ul className="text-xs">
{notes.map((note, i) => (
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
))}
</ul>
</>
)}
</div>
<div className="border border-neutral-900">
<div className="space-y-1.5 px-3.5 py-3 text-xs">
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
{t.discountTotal > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(t.discountTotal)}</span></div>}
{t.taxTotal > 0 && <div className="flex justify-between"><span>Tax</span><span className="tabular-nums">{money(t.taxTotal)}</span></div>}
{t.roundOff !== 0 && <div className="flex justify-between"><span>Round off</span><span className="tabular-nums">{money(t.roundOff)}</span></div>}
</div>
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
<span>Total</span>
<span className="tabular-nums">{money(t.netPayable)}</span>
</div>
{t.paidAmount > 0 && (
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
</div>
)}
</div>
</div>
{/* Payment method */}
<div className="mt-8 print:break-inside-avoid">
<SectionTitle>Payment Method</SectionTitle>
{payments.length > 0 ? (
<ul className="text-xs">
{payments.map((p) => (
<li key={p.salesInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
<span>
{formatDate(p.paymentDate)} · {p.bankAccountName}
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
</span>
<span className="font-medium tabular-nums">{money(p.amount)}</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
)}
{showBank && (
<div className="mt-3 text-xs leading-relaxed">
<p className="font-bold text-neutral-900">Bank transfer</p>
{company!.bankName && <p>Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</p>}
{company!.accountName && <p>Account name: {company!.accountName}</p>}
{company!.accountNumber && <p>Account number: {company!.accountNumber}</p>}
{company!.swiftCode && <p>SWIFT: {company!.swiftCode}</p>}
</div>
)}
</div>
</div>
)
}
/** Dashed divider used between receipt sections. */
function ReceiptRule() {
return <div className="my-2 border-t border-dashed border-neutral-500" />
}
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
<span>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
)
}
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
const t = invoice.totals
return (
<div
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
style={printColorExact}
>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
)}
{company && (
<>
<p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>
<p>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
{company.phone && <p>Tel: {company.phone}</p>}
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
</>
)}
<p className="mt-2 text-base font-extrabold tracking-[0.2em]">INVOICE</p>
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
</div>
<ReceiptRule />
<ReceiptRow label="Invoice No." value={invoice.invoiceNo} />
<ReceiptRow label="Date" value={formatDate(invoice.invoiceDate)} />
<ReceiptRow label="Due" value={formatDate(dueDate)} />
<ReceiptRow label="Customer" value={invoice.customerSnapshotName} />
{customer?.phone && <ReceiptRow label="Phone" value={customer.phone} />}
<ReceiptRule />
<div className="flex justify-between font-bold">
<span>Item</span>
<span>Amount</span>
</div>
<ReceiptRule />
<div className="space-y-1.5">
{invoice.lines.map((line) => (
<div key={line.salesInvoiceLineId}>
<p className="font-medium">{line.description}{line.isFreeIssue ? " (free)" : ""}</p>
<div className="flex justify-between gap-2 tabular-nums">
<span>
{qty(line.qty)} x {money(line.unitPrice)}
{line.freeQty > 0 && ` + ${qty(line.freeQty)} free`}
</span>
<span>{money(line.lineTotal)}</span>
</div>
{line.discountAmount > 0 && <p className="text-right tabular-nums">Disc -{money(line.discountAmount)}</p>}
</div>
))}
</div>
<ReceiptRule />
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
{t.discountTotal > 0 && <ReceiptRow label="Discount" value={`-${money(t.discountTotal)}`} />}
{t.taxTotal > 0 && <ReceiptRow label="Tax" value={money(t.taxTotal)} />}
{t.roundOff !== 0 && <ReceiptRow label="Round off" value={money(t.roundOff)} />}
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
<ReceiptRow label="TOTAL" value={money(t.netPayable)} bold />
</div>
{t.paidAmount > 0 && (
<>
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
</>
)}
{payments.length > 0 && (
<>
<ReceiptRule />
<p className="font-bold">Payment</p>
{payments.map((p) => (
<ReceiptRow key={p.salesInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
))}
</>
)}
{notes.length > 0 && (
<>
<ReceiptRule />
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
</>
)}
<ReceiptRule />
<p className="text-center font-bold">Thank you!</p>
</div>
)
}
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
* template). Only the invoice is required — any other failure just leaves that block out. */
function useInvoiceDoc(invoiceId: number) {
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
useEffect(() => {
if (!Number.isFinite(invoiceId)) return
let cancelled = false
salesApi
.getInvoice(invoiceId)
.then(async ({ data: invoice }) => {
const [cust, payments, companyRes, templateRes] = await Promise.all([
customersApi.get(invoice.customerId).catch(() => null),
salesApi.listInvoicePayments(invoiceId).catch(() => []),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
if (cancelled) return
const customer = cust?.data ?? null
const company = companyRes.data
const template = templateRes.data
// Invoices carry no due date of their own — it's the customer's credit terms from the invoice date.
const dueDate = new Date(invoice.invoiceDate)
dueDate.setDate(dueDate.getDate() + (customer?.creditDays ?? 0))
setDoc({
invoice,
customer,
payments,
company,
template,
dueDate,
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
.map((l) => l.trim())
.filter(Boolean),
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
})
})
.catch((err) => {
if (!cancelled) setLoadError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [invoiceId])
return { doc, error }
}
const subscribeNoop = () => () => {}
/** True only on the client — the print copy is portalled into document.body. */
function useIsClient() {
return useSyncExternalStore(subscribeNoop, () => true, () => false)
}
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
}
/**
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
* just the invoice, whether this is rendered in a page or inside a dialog.
*/
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
const { doc, error } = useInvoiceDoc(invoiceId)
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const isClient = useIsClient()
if (error && !doc) {
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
return (
<div className={cn("flex min-h-0 flex-col", className)}>
{/* Preview window — scrolls so the page keeps its true size. */}
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
{doc ? (
<Sheet size={paperSize} doc={doc} />
) : (
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
)}
</div>
{/* Paper size selection, under the invoice. */}
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<span className="text-sm font-semibold text-foreground">Paper size</span>
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
{PAPER_SIZES.map((s) => (
<button
key={s.value}
type="button"
role="radio"
aria-checked={paperSize === s.value}
onClick={() => setPaperSize(s.value)}
className={cn(
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
)}
>
<span className="text-sm font-medium">{s.label}</span>
<span className="text-xs text-muted-foreground">{s.hint}</span>
</button>
))}
</div>
</div>
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
<Printer className="size-4" />
Print {paperSize === "A4" ? "A4" : "receipt"}
</Button>
</div>
{isClient &&
doc &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
<Sheet size={paperSize} doc={doc} />
</div>,
document.body
)}
</div>
)
}
/** Popup form of the print preview. Opens on A4 every time (the content remounts per open). */
export function SalesInvoicePrintDialog({
invoiceId,
invoiceNo,
open,
onOpenChange,
}: {
invoiceId: number | null
invoiceNo?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
<div className="border-b px-5 py-4 pr-12">
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
</div>
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
</DialogContent>
</Dialog>
)
}