ui and bug fixers 29/09

This commit is contained in:
2026-09-29 14:49:11 +05:30
parent d24072e20a
commit b2646c6cb3
85 changed files with 30050 additions and 750 deletions
@@ -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>
)
}