ui bug fixers

This commit is contained in:
2026-10-07 10:28:29 +05:30
committed by ImanThiyanga
parent 0d3f2fba14
commit 3abcfa6e6f
37 changed files with 1852 additions and 542 deletions
@@ -0,0 +1,286 @@
"use client"
import { ReactNode, useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Download, Loader2, Printer, Share2 } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
/*
* Shared plumbing for the printable invoice previews (sales and purchase): paper sizes, the
* sheet's building blocks, PDF export, and the preview frame with its Paper size / Share /
* Download / Print bar. Each invoice type only supplies its own A4 and receipt layouts.
*/
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
export const ACCENT = "#8b1c24"
export type PaperSize = "A4" | "80mm" | "58mm"
export 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. */
export 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]",
}
/** Screen chrome for the on-screen A4 page; in print the @page margin does the spacing. */
export const A4_SHEET_CLASS =
"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"
/** Screen chrome for a receipt roll; combine with RECEIPT_SHEET_CLASS[width]. */
export const RECEIPT_SHEET_BASE_CLASS =
"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"
export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
export function money(n: number) {
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function qty(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
/** dd/MM/yyyy, as on the reference invoice. */
export 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()}`
}
/** Section heading with the short accent underline. */
export function SectionTitle({ children }: { children: 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>
)
}
/** Dashed divider used between receipt sections. */
export function ReceiptRule() {
return <div className="my-2 border-t border-dashed border-neutral-500" />
}
export 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>
)
}
/** Terms + footer note lines from the invoice template, blank lines dropped. */
export function templateNotes(termsAndConditions?: string | null, footerNote?: string | null): string[] {
return [...(termsAndConditions ?? "").split("\n"), ...(footerNote ?? "").split("\n")].map((l) => l.trim()).filter(Boolean)
}
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)
}
/**
* Invoice preview + paper-size bar underneath + Share / Download / 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 InvoicePreviewFrame({
renderSheet,
error,
docNo,
shareTitle,
sharePath,
className,
}: {
/** The invoice laid out for a paper size, or null while it is still loading. */
renderSheet: ((size: PaperSize) => ReactNode) | null
error: string | null
/** Used for the PDF file name. */
docNo: string
shareTitle: string
/** App path of a page showing this invoice — shared/copied where the PDF itself can't be. */
sharePath: string
className?: string
}) {
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const isClient = useIsClient()
const previewRef = useRef<HTMLDivElement>(null)
const [busy, setBusy] = useState<"download" | "share" | null>(null)
const fileName = `${docNo}${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 page.
async function handleShare() {
const link = `${window.location.origin}${sharePath}`
setBusy("share")
try {
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title: shareTitle, text: shareTitle })
} else if (navigator.share) {
await navigator.share({ title: shareTitle, text: shareTitle, 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 && !renderSheet) {
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">
{renderSheet ? renderSheet(paperSize) : <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={!renderSheet || busy !== null}>
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
Share
</Button>
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!renderSheet || busy !== null}>
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
Download
</Button>
<Button size="lg" onClick={() => window.print()} disabled={!renderSheet}>
<Printer className="size-4" />
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
</Button>
</div>
</div>
{isClient &&
renderSheet &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
{renderSheet(paperSize)}
</div>,
document.body
)}
</div>
)
}