ui and bug fixers 29/09
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
// Shared print/download/send shell for printable documents (sales invoice, GRN, …). Each
|
||||
// document supplies its own sheet layouts; this owns the paper-size picker, the print copy,
|
||||
// PDF export and sharing, so every document behaves the same way.
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Download, Printer, Send } from "lucide-react"
|
||||
|
||||
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"
|
||||
|
||||
// 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"
|
||||
|
||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||
{ value: "80mm", label: "80mm", hint: "Receipt roll" },
|
||||
{ value: "58mm", label: "58mm", hint: "Receipt 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; }",
|
||||
}
|
||||
|
||||
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()}`
|
||||
}
|
||||
|
||||
export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
|
||||
|
||||
/** On screen: a true 210 × 297 mm 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"
|
||||
|
||||
/** Thermal roll sheet (80mm, or 58mm when narrow): one centred column, no fills. */
|
||||
export function receiptSheetClass(narrow?: boolean) {
|
||||
return 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",
|
||||
narrow ? "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]" : "w-[80mm] px-[4mm] py-[5mm] text-[11px]"
|
||||
)
|
||||
}
|
||||
|
||||
/** Section heading with the short accent underline. */
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Heavy dark bar over a thin accent line, under the A4 title block. */
|
||||
export function HeaderRule() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
|
||||
const subscribeNoop = () => () => {}
|
||||
|
||||
/** True only on the client — the print copy is portalled into document.body. */
|
||||
function useIsClient() {
|
||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||
}
|
||||
|
||||
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||
const A4_HEIGHT_MM = 297
|
||||
// Stands in for an image that can't be embedded (e.g. a logo on a host without CORS) so the PDF still renders.
|
||||
const BLANK_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
|
||||
/** Renders the on-screen sheet to a PDF: A4 is split across pages, receipts are one long page. */
|
||||
async function renderSheetPdf(node: HTMLElement, size: PaperSize, fileName: string): Promise<File> {
|
||||
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||
const image = await toPng(node, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
style: { boxShadow: "none", margin: "0" },
|
||||
imagePlaceholder: BLANK_PNG,
|
||||
})
|
||||
const widthMm = PAPER_WIDTH_MM[size]
|
||||
const heightMm = (node.offsetHeight / node.offsetWidth) * widthMm
|
||||
|
||||
let pdf
|
||||
if (size === "A4") {
|
||||
pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||
for (let top = 0; top < heightMm - 1; top += A4_HEIGHT_MM) {
|
||||
if (top > 0) pdf.addPage()
|
||||
pdf.addImage(image, "PNG", 0, -top, widthMm, heightMm, "sheet")
|
||||
}
|
||||
} else {
|
||||
pdf = new jsPDF({ unit: "mm", format: [widthMm, heightMm], orientation: heightMm >= widthMm ? "portrait" : "landscape" })
|
||||
pdf.addImage(image, "PNG", 0, 0, widthMm, heightMm)
|
||||
}
|
||||
return new File([pdf.output("blob")], fileName, { type: "application/pdf" })
|
||||
}
|
||||
|
||||
function downloadFile(file: File) {
|
||||
const url = URL.createObjectURL(file)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.name
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
/** wa.me link to a phone (local 0XXXXXXXXX numbers get the 94 country code); no phone opens the contact picker. */
|
||||
function whatsAppUrl(phone: string | null | undefined, text: string) {
|
||||
let digits = (phone ?? "").replace(/\D/g, "")
|
||||
if (digits.startsWith("0")) digits = `94${digits.slice(1)}`
|
||||
return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
title: string
|
||||
text: string
|
||||
/** WhatsApp fallback recipient; omitted opens WhatsApp's contact picker. */
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Document preview + paper-size picker + Download / Send / Print. Download and Send render the
|
||||
* previewed sheet to a PDF in the chosen size. Printing uses a copy of the sheet portalled
|
||||
* straight into <body> (`.invoice-print-root`, see globals.css) so the printout is just the
|
||||
* document, whether this is rendered in a page or inside a dialog.
|
||||
*/
|
||||
export function DocumentPrintPreview({
|
||||
renderSheet,
|
||||
error,
|
||||
fileName,
|
||||
share,
|
||||
className,
|
||||
}: {
|
||||
/** Null while the document is loading. */
|
||||
renderSheet: ((size: PaperSize) => React.ReactNode) | null
|
||||
error: string | null
|
||||
/** PDF file name without extension; the paper size is appended for receipts. */
|
||||
fileName: string
|
||||
share: ShareInfo | null
|
||||
className?: string
|
||||
}) {
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
const [busy, setBusy] = useState<"download" | "send" | null>(null)
|
||||
const sheetRef = useRef<HTMLDivElement>(null)
|
||||
const isClient = useIsClient()
|
||||
const ready = renderSheet !== null
|
||||
|
||||
async function makePdf() {
|
||||
const node = sheetRef.current?.firstElementChild
|
||||
if (!(node instanceof HTMLElement)) throw new Error("The document isn't ready yet.")
|
||||
const suffix = paperSize === "A4" ? "" : `-${paperSize}`
|
||||
return renderSheetPdf(node, paperSize, `${fileName}${suffix}.pdf`)
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy("download")
|
||||
try {
|
||||
downloadFile(await makePdf())
|
||||
} catch (err) {
|
||||
toast.error("Could not create PDF", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Share sheet with the PDF attached where the browser supports it; otherwise download it and open WhatsApp. */
|
||||
async function handleSend() {
|
||||
if (!share) return
|
||||
setBusy("send")
|
||||
try {
|
||||
const file = await makePdf()
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: share.title, text: share.text })
|
||||
return
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return
|
||||
// NotAllowedError: the click's activation expired while the PDF rendered — fall through to WhatsApp.
|
||||
if (!(err instanceof DOMException && err.name === "NotAllowedError")) throw err
|
||||
}
|
||||
}
|
||||
downloadFile(file)
|
||||
window.open(whatsAppUrl(share.phone, share.text), "_blank", "noopener,noreferrer")
|
||||
toast.info("PDF downloaded", "Attach it to the WhatsApp chat that just opened.")
|
||||
} catch (err) {
|
||||
toast.error("Could not send", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !ready) {
|
||||
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">
|
||||
{ready ? (
|
||||
<div ref={sheetRef}>{renderSheet(paperSize)}</div>
|
||||
) : (
|
||||
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 items-center gap-3">
|
||||
<label htmlFor="document-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||
<Select<PaperSize>
|
||||
value={paperSize}
|
||||
onValueChange={(v) => setPaperSize(v ?? "A4")}
|
||||
items={PAPER_SIZES.map((s) => ({ label: `${s.label} — ${s.hint}`, value: s.value }))}
|
||||
>
|
||||
<SelectTrigger id="document-paper-size" className="h-10! w-48 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label} — {s.hint}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:flex">
|
||||
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!ready || busy !== null}>
|
||||
<Download className="size-4" />
|
||||
{busy === "download" ? "Preparing…" : "Download"}
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={handleSend} disabled={!ready || !share || busy !== null}>
|
||||
<Send className="size-4" />
|
||||
{busy === "send" ? "Preparing…" : "Send"}
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!ready || busy !== null}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
ready &&
|
||||
createPortal(
|
||||
<div className="invoice-print-root">
|
||||
<style>{PAGE_RULES[paperSize]}</style>
|
||||
{renderSheet(paperSize)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Popup frame for a document preview. The content remounts per open, so it starts on A4 each time. */
|
||||
export function DocumentPrintDialog({
|
||||
title,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
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">{title}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then download, send or print.</DialogDescription>
|
||||
</div>
|
||||
{open && children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user