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
@@ -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>
)
}