ui and bug fixers 29/09
This commit is contained in:
@@ -1,53 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Printer } from "lucide-react"
|
||||
import { useEffect, useState } from "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 {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
DocumentPrintDialog,
|
||||
DocumentPrintPreview,
|
||||
HeaderRule,
|
||||
PaperSize,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
SectionTitle,
|
||||
formatDate,
|
||||
money,
|
||||
printColorExact,
|
||||
qty,
|
||||
receiptSheetClass,
|
||||
} from "@/components/print/DocumentPrint"
|
||||
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
|
||||
@@ -61,26 +41,10 @@ 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>
|
||||
@@ -106,11 +70,7 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
|
||||
)}
|
||||
</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>
|
||||
<HeaderRule />
|
||||
|
||||
{/* From / Bill To */}
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
@@ -251,28 +211,21 @@ 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 thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||
/** Thermal receipt layout (80mm or 58mm roll): one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({
|
||||
invoice,
|
||||
customer,
|
||||
payments,
|
||||
company,
|
||||
template,
|
||||
dueDate,
|
||||
notes,
|
||||
showLogo,
|
||||
narrow,
|
||||
}: InvoiceDoc & { narrow?: boolean }) {
|
||||
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={receiptSheetClass(narrow)} style={printColorExact}>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
@@ -409,85 +362,31 @@ function useInvoiceDoc(invoiceId: number) {
|
||||
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} />
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function shareText({ invoice, company }: InvoiceDoc) {
|
||||
const from = company ? ` from ${company.tradeName || company.legalName}` : ""
|
||||
return `Invoice ${invoice.invoiceNo}${from} — total ${money(invoice.totals.netPayable)}` +
|
||||
(invoice.totals.balanceAmount > 0 ? `, balance due ${money(invoice.totals.balanceAmount)}.` : ".")
|
||||
}
|
||||
|
||||
/** Invoice preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
|
||||
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>
|
||||
<DocumentPrintPreview
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
fileName={doc?.invoice.invoiceNo ?? "invoice"}
|
||||
share={doc ? { title: `Invoice ${doc.invoice.invoiceNo}`, text: shareText(doc), phone: doc.customer?.phone } : null}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Popup form of the print preview. Opens on A4 every time (the content remounts per open). */
|
||||
/** Popup form of the invoice preview. */
|
||||
export function SalesInvoicePrintDialog({
|
||||
invoiceId,
|
||||
invoiceNo,
|
||||
@@ -500,14 +399,8 @@ export function SalesInvoicePrintDialog({
|
||||
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>
|
||||
<DocumentPrintDialog title={`Invoice${invoiceNo ? ` ${invoiceNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||
</DocumentPrintDialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user