fixing recipts in sales
This commit is contained in:
@@ -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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user