ui bug fixers
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
||||
import { ReactNode, useEffect, useState } from "react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -10,55 +8,29 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
formatDate,
|
||||
InvoicePreviewFrame,
|
||||
money,
|
||||
PaperSize,
|
||||
printColorExact,
|
||||
qty,
|
||||
RECEIPT_SHEET_BASE_CLASS,
|
||||
RECEIPT_SHEET_CLASS,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
ReceiptWidth,
|
||||
SectionTitle,
|
||||
templateNotes,
|
||||
} from "@/components/invoice-template/InvoicePrintKit"
|
||||
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" | "58mm"
|
||||
type ReceiptWidth = Exclude<PaperSize, "A4">
|
||||
|
||||
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" },
|
||||
{ value: "58mm", label: "58mm receipt", hint: "Small 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; }",
|
||||
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
||||
}
|
||||
|
||||
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
||||
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
||||
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
||||
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
||||
}
|
||||
|
||||
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
|
||||
@@ -72,26 +44,11 @@ interface InvoiceDoc {
|
||||
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}
|
||||
>
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
{/* Title + meta */}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
@@ -262,31 +219,11 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
|
||||
RECEIPT_SHEET_CLASS[width]
|
||||
)}
|
||||
style={printColorExact}
|
||||
>
|
||||
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
@@ -405,9 +342,7 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
||||
company,
|
||||
template,
|
||||
dueDate,
|
||||
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
|
||||
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
||||
})
|
||||
@@ -424,186 +359,22 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
||||
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)
|
||||
}
|
||||
|
||||
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
||||
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
/**
|
||||
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
||||
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
||||
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
||||
* Both libraries are loaded on demand.
|
||||
*/
|
||||
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
||||
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||
// The sheet's drop shadow / ring are screen decoration only.
|
||||
const dataUrl = await toPng(sheet, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
style: { boxShadow: "none", margin: "0" },
|
||||
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
||||
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||
})
|
||||
const img = new Image()
|
||||
img.src = dataUrl
|
||||
await img.decode()
|
||||
|
||||
const widthMm = PAPER_WIDTH_MM[size]
|
||||
const imgHeightMm = (img.height * widthMm) / img.width
|
||||
if (size !== "A4") {
|
||||
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
||||
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
||||
return pdf.output("blob")
|
||||
}
|
||||
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
||||
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
||||
if (page > 0) pdf.addPage()
|
||||
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
||||
}
|
||||
return pdf.output("blob")
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Invoice preview + paper-size bar underneath + Share / Download / Print (see InvoicePreviewFrame). */
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
const isClient = useIsClient()
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
||||
|
||||
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
||||
|
||||
async function makePdf(): Promise<Blob> {
|
||||
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
||||
if (!sheet) throw new Error("The invoice is still loading.")
|
||||
return buildInvoicePdf(sheet, paperSize)
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy("download")
|
||||
try {
|
||||
saveBlob(await makePdf(), fileName)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
||||
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
|
||||
async function handleShare() {
|
||||
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
|
||||
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
|
||||
setBusy("share")
|
||||
try {
|
||||
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title, text: title })
|
||||
} else if (navigator.share) {
|
||||
await navigator.share({ title, text: title, url: link })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
||||
}
|
||||
} catch (err) {
|
||||
// Closing the share sheet isn't an error.
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
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 ref={previewRef} 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">
|
||||
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||
<Select<PaperSize>
|
||||
value={paperSize}
|
||||
onValueChange={(v) => v && setPaperSize(v)}
|
||||
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
>
|
||||
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
|
||||
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
||||
Share
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
|
||||
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
Download
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
doc &&
|
||||
createPortal(
|
||||
<div className="invoice-print-root">
|
||||
<style>{PAGE_RULES[paperSize]}</style>
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
<InvoicePreviewFrame
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
docNo={doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}
|
||||
shareTitle={`Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()}
|
||||
sharePath={`/dashboard/sales/invoices/${invoiceId}/print`}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user