ui and bug fixers 29/09
This commit is contained in:
@@ -43,7 +43,6 @@ import {
|
||||
PieChart,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
Ruler,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
@@ -98,8 +97,9 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
// One entry for the merged Brand/UOM tab page (the old /uoms route just redirects to
|
||||
// its UOM tab) — still shown to a role that only holds the UOM permission.
|
||||
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
|
||||
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
||||
],
|
||||
},
|
||||
@@ -437,7 +437,9 @@ export function AppSidebar() {
|
||||
const pathname = usePathname() || "/"
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { navCodes, loading } = useAuth()
|
||||
const { navCodes, loading, branchingEnabled } = useAuth()
|
||||
// Entries that only make sense while "Require branches" is on — hidden entirely when off.
|
||||
const branchOnlyCodes = new Set(["settings.branches"])
|
||||
|
||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
@@ -463,9 +465,10 @@ export function AppSidebar() {
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: bypassCodes.has(item.code)
|
||||
children: (bypassCodes.has(item.code)
|
||||
? item.children
|
||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
|
||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||
)?.filter((c) => branchingEnabled || !branchOnlyCodes.has(c.code)),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
@@ -32,6 +33,10 @@ function readStoredActiveBranch(): ActiveBranch | null {
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
/** Re-fetches `/auth/me` — call after changing settings it reflects (e.g. Branch Configuration). */
|
||||
refresh: () => void
|
||||
/** The caller's local user id — compare against a document's `createdBy`. */
|
||||
userId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
@@ -62,11 +67,19 @@ interface AuthContextValue {
|
||||
* everything else. Always false when `branchingEnabled` itself is false.
|
||||
*/
|
||||
warehouseBranchingEnabled: boolean
|
||||
/**
|
||||
* Independent sub-toggle for Purchase Orders (Settings → Branch Configuration). False means
|
||||
* POs are one shared pool: no branch picker/column on PO screens. Always false when
|
||||
* `branchingEnabled` is false.
|
||||
*/
|
||||
purchaseOrderBranchingEnabled: boolean
|
||||
/** True until the first `/auth/me` response lands. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
refresh: () => {},
|
||||
userId: null,
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
@@ -78,12 +91,17 @@ const AuthContext = createContext<AuthContextValue>({
|
||||
setActiveBranch: () => {},
|
||||
branchingEnabled: true,
|
||||
warehouseBranchingEnabled: true,
|
||||
purchaseOrderBranchingEnabled: true,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
|
||||
// Bumped by refresh() to re-fetch /auth/me — e.g. right after Branch Configuration changes,
|
||||
// so the header and every branch-aware screen update without a full page reload.
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -95,12 +113,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true, mustChangePassword: false, purchaseOrderBranchingEnabled: true, userId: null })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [reloadKey])
|
||||
|
||||
useEffect(() => {
|
||||
// Admin-created accounts must replace their issued password before using the app.
|
||||
if (me?.mustChangePassword) router.replace("/change-password")
|
||||
}, [me, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (me === null) return
|
||||
@@ -115,21 +138,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
// With branching switched off there is no "current branch" at all — ignore any stale
|
||||
// header pick (or own-branch pin) so no screen keeps silently filtering by it.
|
||||
const branchingEnabled = me?.branchingEnabled ?? true
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
refresh: () => setReloadKey((k) => k + 1),
|
||||
userId: me?.userId ?? null,
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
isAdmin: me?.isAdmin ?? false,
|
||||
branchId: me?.branchId ?? null,
|
||||
branchName: me?.branchName ?? null,
|
||||
activeBranchId: activeBranch?.id ?? null,
|
||||
activeBranchName: activeBranch?.name ?? null,
|
||||
activeBranchId: branchingEnabled ? (activeBranch?.id ?? null) : null,
|
||||
activeBranchName: branchingEnabled ? (activeBranch?.name ?? null) : null,
|
||||
setActiveBranch,
|
||||
branchingEnabled: me?.branchingEnabled ?? true,
|
||||
branchingEnabled,
|
||||
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true,
|
||||
loading: me === null,
|
||||
purchaseOrderBranchingEnabled: me?.purchaseOrderBranchingEnabled ?? true,
|
||||
// Stays "loading" while a forced password change redirects away, so the shell never flashes.
|
||||
loading: me === null || me.mustChangePassword,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
DocumentPrintDialog,
|
||||
DocumentPrintPreview,
|
||||
HeaderRule,
|
||||
PaperSize,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
SectionTitle,
|
||||
formatDate,
|
||||
money,
|
||||
printColorExact,
|
||||
qty,
|
||||
receiptSheetClass,
|
||||
} from "@/components/print/DocumentPrint"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Grn, GrnLine, HoldStatus } from "@/types/grn"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { Item, Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
/** Everything both layouts render, resolved once. */
|
||||
interface GrnDoc {
|
||||
grn: Grn
|
||||
vendor: Vendor | null
|
||||
warehouse: Warehouse | null
|
||||
poNo: string | null
|
||||
items: Map<number, Item>
|
||||
company: CompanyProfile | null
|
||||
showLogo: boolean
|
||||
/** Print the warehouse's branch — only meaningful while warehouses are divided by branch. */
|
||||
showBranch?: boolean
|
||||
totals: { gross: number; discount: number; net: number; vat: number; total: number }
|
||||
}
|
||||
|
||||
const HOLD_LABEL: Record<HoldStatus, string> = { Available: "", OnHold: "On hold", Rejected: "Rejected" }
|
||||
|
||||
function itemLabel(doc: GrnDoc, line: GrnLine) {
|
||||
const item = doc.items.get(line.itemId)
|
||||
return item ? item.name : `Item #${line.itemId}`
|
||||
}
|
||||
|
||||
function A4Sheet(doc: GrnDoc) {
|
||||
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
|
||||
return (
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
{/* Title + meta */}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIPT NOTE</h1>
|
||||
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||
<dt className="text-xs font-bold text-neutral-900">GRN No.</dt>
|
||||
<dd>{grn.docNo}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Received Date</dt>
|
||||
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Purchase Order</dt>
|
||||
<dd>{grn.poId ? (poNo ?? `PO #${grn.poId}`) : "Direct receipt"}</dd>
|
||||
{grn.purchaseInvoiceNo && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Purchase Invoice</dt>
|
||||
<dd>{grn.purchaseInvoiceNo}</dd>
|
||||
</>
|
||||
)}
|
||||
{grn.status !== "Confirmed" && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{grn.status.toUpperCase()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HeaderRule />
|
||||
|
||||
{/* Supplier / Received at */}
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>Supplier</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
|
||||
{vendor && <p>Code: {vendor.code}</p>}
|
||||
{vendor?.taxReg && <p>Tax Reg No: {vendor.taxReg}</p>}
|
||||
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>Received At</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
{company && <p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>}
|
||||
<p>{warehouse ? `${warehouse.name} (${warehouse.code})` : `Warehouse #${grn.warehouseId}`}</p>
|
||||
{warehouse?.address && <p>{warehouse.address}</p>}
|
||||
{doc.showBranch && warehouse?.branchName && <p>Branch: {warehouse.branchName}</p>}
|
||||
{warehouse?.phone && <p>{warehouse.phone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Items Received</SectionTitle>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||
<th className="px-2 py-2.5 text-left">Item</th>
|
||||
<th className="px-2 py-2.5 text-center">Qty</th>
|
||||
<th className="px-2 py-2.5 text-right">Unit Cost</th>
|
||||
<th className="px-2 py-2.5 text-right">Discount</th>
|
||||
<th className="px-2 py-2.5 text-right">VAT</th>
|
||||
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grn.lines.map((line) => {
|
||||
const item = doc.items.get(line.itemId)
|
||||
return (
|
||||
<tr key={line.grnLineId} className="border-b border-neutral-300 align-top">
|
||||
<td className="px-2 py-2.5">
|
||||
<p>{itemLabel(doc, line)}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{[
|
||||
item?.sku && `SKU ${item.sku}`,
|
||||
HOLD_LABEL[line.holdStatus],
|
||||
].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{line.warrantyNumbers.length > 0 && (
|
||||
<p className="text-xs text-neutral-500">Warranty: {line.warrantyNumbers.map((w) => w.warrantyNo).join(", ")}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center tabular-nums">{qty(line.qty)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitCost)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountPct > 0 ? `${qty(line.discountPct)}%` : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatAmount > 0 ? money(line.vatAmount) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="mt-6 flex justify-end print:break-inside-avoid">
|
||||
<div className="w-72.5 border border-neutral-900">
|
||||
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Gross</span><span className="tabular-nums">{money(totals.gross)}</span></div>
|
||||
{totals.discount > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(totals.discount)}</span></div>}
|
||||
{totals.vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(totals.vat)}</span></div>}
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">{money(totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign-off */}
|
||||
<div className="mt-16 grid grid-cols-3 gap-8 text-xs print:break-inside-avoid">
|
||||
{["Received by", "Checked by", "Approved by"].map((label) => (
|
||||
<div key={label}>
|
||||
<div className="border-t border-neutral-900 pt-1.5 font-bold text-neutral-900">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReceiptSheet({ narrow, ...doc }: GrnDoc & { narrow?: boolean }) {
|
||||
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
|
||||
return (
|
||||
<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
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||
)}
|
||||
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
|
||||
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIPT NOTE</p>
|
||||
{grn.status !== "Confirmed" && <p className="font-bold">*** {grn.status.toUpperCase()} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="GRN No." value={grn.docNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
|
||||
<ReceiptRow label="PO" value={grn.poId ? (poNo ?? `#${grn.poId}`) : "Direct"} />
|
||||
<ReceiptRow label="Supplier" value={vendor?.name ?? `#${grn.vendorId}`} />
|
||||
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>Item</span>
|
||||
<span>Amount</span>
|
||||
</div>
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{grn.lines.map((line) => (
|
||||
<div key={line.grnLineId}>
|
||||
<p className="font-medium">
|
||||
{itemLabel(doc, line)}
|
||||
{HOLD_LABEL[line.holdStatus] && ` (${HOLD_LABEL[line.holdStatus].toLowerCase()})`}
|
||||
</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>{qty(line.qty)} x {money(line.unitCost)}</span>
|
||||
<span>{money(line.lineTotal)}</span>
|
||||
</div>
|
||||
{line.discountPct > 0 && <p className="text-right tabular-nums">Disc {qty(line.discountPct)}%</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Gross" value={money(totals.gross)} />
|
||||
{totals.discount > 0 && <ReceiptRow label="Discount" value={`-${money(totals.discount)}`} />}
|
||||
{totals.vat > 0 && <ReceiptRow label="VAT" value={money(totals.vat)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(totals.total)} bold />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="border-t border-black pt-1">Received by</div>
|
||||
<div className="border-t border-black pt-1">Checked by</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
|
||||
}
|
||||
|
||||
/** Loads the GRN plus its decoration (vendor, warehouse, PO no, item names, company). Only the
|
||||
* GRN is required — any other failure just leaves that detail out. */
|
||||
function useGrnDoc(grnId: number) {
|
||||
const [doc, setDoc] = useState<GrnDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(grnId) ? loadError : `Invalid GRN id '${grnId}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
let cancelled = false
|
||||
grnsApi
|
||||
.get(grnId)
|
||||
.then(async (grn) => {
|
||||
const itemIds = [...new Set(grn.lines.map((l) => l.itemId))]
|
||||
const [vendorRes, warehouse, po, itemRes, companyRes, templateRes] = await Promise.all([
|
||||
vendorsApi.get(grn.vendorId).catch(() => null),
|
||||
warehousesApi.get(grn.warehouseId).catch(() => null),
|
||||
grn.poId ? purchaseOrdersApi.get(grn.poId).catch(() => null) : Promise.resolve(null),
|
||||
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
if (cancelled) return
|
||||
const company = companyRes.data
|
||||
const template: InvoiceTemplateConfig | null = templateRes.data
|
||||
|
||||
const gross = grn.lines.reduce((s, l) => s + l.qty * l.unitCost, 0)
|
||||
const net = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
|
||||
setDoc({
|
||||
grn,
|
||||
vendor: vendorRes?.data ?? null,
|
||||
warehouse,
|
||||
poNo: po?.docNo ?? null,
|
||||
items: new Map(itemRes.filter((i): i is Item => i !== null).map((i) => [i.itemId, i])),
|
||||
company,
|
||||
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
totals: { gross, discount: gross - net, net, vat, total: grn.lines.reduce((s, l) => s + l.lineTotal, 0) },
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [grnId])
|
||||
|
||||
// "Divide warehouses by branch" is always off when "Require branches" is, so this one
|
||||
// flag covers both switches.
|
||||
const { warehouseBranchingEnabled } = useAuth()
|
||||
return { doc: doc && { ...doc, showBranch: warehouseBranchingEnabled }, error }
|
||||
}
|
||||
|
||||
function shareText({ grn, vendor, company, totals }: GrnDoc) {
|
||||
const at = company ? ` at ${company.tradeName || company.legalName}` : ""
|
||||
const from = vendor ? ` from ${vendor.name}` : ""
|
||||
return `Goods Receipt Note ${grn.docNo}${at} — goods received${from}, total ${money(totals.total)}.`
|
||||
}
|
||||
|
||||
/** GRN preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
|
||||
export function GrnPrintPreview({ grnId, className }: { grnId: number; className?: string }) {
|
||||
const { doc, error } = useGrnDoc(grnId)
|
||||
return (
|
||||
<DocumentPrintPreview
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
fileName={doc?.grn.docNo ?? "grn"}
|
||||
share={doc ? { title: `GRN ${doc.grn.docNo}`, text: shareText(doc) } : null}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Popup form of the GRN preview. */
|
||||
export function GrnPrintDialog({
|
||||
grnId,
|
||||
docNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
grnId: number | null
|
||||
docNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<DocumentPrintDialog title={`GRN${docNo ? ` ${docNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
|
||||
{grnId !== null && <GrnPrintPreview grnId={grnId} className="flex-1" />}
|
||||
</DocumentPrintDialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
|
||||
const warning = "bg-warning/10 text-warning border-transparent"
|
||||
const success = "bg-success/10 text-success border-transparent"
|
||||
const destructive = "bg-destructive/10 text-destructive border-transparent"
|
||||
const info = "bg-info/10 text-info border-transparent"
|
||||
|
||||
export function TransferStatusBadge({ status }: { status: TransferStatus }) {
|
||||
const tone = status === "Received" ? success : status === "Closed" ? destructive : warning
|
||||
@@ -18,7 +19,7 @@ export function TransferStatusBadge({ status }: { status: TransferStatus }) {
|
||||
}
|
||||
|
||||
export function CountStatusBadge({ status }: { status: CountStatus }) {
|
||||
const tone = status === "Posted" ? success : warning
|
||||
const tone = status === "Posted" ? success : status === "Counted" ? info : warning
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
|
||||
{status}
|
||||
|
||||
@@ -19,6 +19,12 @@ interface ComboboxProps<Value> {
|
||||
emptyText?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** Extra classes for the dropdown popup — e.g. a larger `min-w-*` so long labels fit
|
||||
* when the trigger sits in a narrow table cell. */
|
||||
popupClassName?: string
|
||||
/** Always open below the input (never flip above it when space is short) — the list
|
||||
* then scrolls within the space that is left below. */
|
||||
openDownward?: boolean
|
||||
"aria-invalid"?: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +38,8 @@ export function Combobox<Value>({
|
||||
emptyText = "No results found.",
|
||||
disabled,
|
||||
className,
|
||||
popupClassName,
|
||||
openDownward,
|
||||
...rest
|
||||
}: ComboboxProps<Value>) {
|
||||
const itemToStringLabel = React.useCallback(
|
||||
@@ -65,10 +73,18 @@ export function Combobox<Value>({
|
||||
/>
|
||||
</ComboboxPrimitive.InputGroup>
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner side="bottom" sideOffset={4} className="isolate z-50">
|
||||
<ComboboxPrimitive.Positioner
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
collisionAvoidance={openDownward ? { side: "none", align: "shift", fallbackAxisSide: "none" } : undefined}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
className="max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
className={cn(
|
||||
"max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
popupClassName
|
||||
)}
|
||||
>
|
||||
<ComboboxPrimitive.Empty className="px-2.5 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface KpiTileSpec {
|
||||
label: string
|
||||
value: number
|
||||
icon?: LucideIcon
|
||||
/** Makes the tile clickable — e.g. to filter the list below it to this metric. */
|
||||
onClick?: () => void
|
||||
/** Highlights the tile as the currently-applied filter. Only meaningful with `onClick`. */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,12 +21,19 @@ export interface KpiTileSpec {
|
||||
*/
|
||||
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{tiles
|
||||
? tiles.map((tile) => (
|
||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||
<StatCard
|
||||
key={tile.label}
|
||||
label={tile.label}
|
||||
value={tile.value}
|
||||
icon={tile.icon}
|
||||
onClick={tile.onClick}
|
||||
active={tile.active}
|
||||
/>
|
||||
))
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-22.5 w-64 rounded-xl" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,55 +14,39 @@ function formatValue(value: string | number) {
|
||||
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
|
||||
}
|
||||
|
||||
function Sparkline({ points }: { points: number[] }) {
|
||||
const width = 48
|
||||
const height = 18
|
||||
const min = Math.min(...points)
|
||||
const max = Math.max(...points)
|
||||
const range = max - min || 1
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = (index / (points.length - 1)) * width
|
||||
const y = height - ((point - min) / range) * height
|
||||
return [x, y] as const
|
||||
})
|
||||
|
||||
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
|
||||
const [lastX, lastY] = coords[coords.length - 1]
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-4.5 w-12 shrink-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="none" className="stroke-muted-foreground/40" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: LucideIcon
|
||||
/** Small subtitle under the value, e.g. "All time". */
|
||||
caption?: string
|
||||
delta?: number
|
||||
deltaLabel?: string
|
||||
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
||||
invert?: boolean
|
||||
trend?: number[]
|
||||
className?: string
|
||||
/** Makes the card an actual button — e.g. to filter the list below it to this metric. */
|
||||
onClick?: () => void
|
||||
/** Highlights the card as the currently-applied filter. Only meaningful with `onClick`. */
|
||||
active?: boolean
|
||||
/** "sm" shrinks padding, icon and text — for tight rows of many cards (e.g. the Dashboard
|
||||
* home overview) that need to fit without wrapping or scrolling. Defaults to "default". */
|
||||
size?: "default" | "sm"
|
||||
}
|
||||
|
||||
/** Icon in a circular badge on the left; label, value and an optional caption stacked to its right. */
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
caption,
|
||||
delta,
|
||||
deltaLabel,
|
||||
invert = false,
|
||||
trend,
|
||||
className,
|
||||
onClick,
|
||||
active = false,
|
||||
size = "default",
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
@@ -70,39 +54,56 @@ export function StatCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
"flex w-fit max-w-full shrink-0 items-center justify-self-start rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
size === "sm" ? "min-w-52 gap-3.5 p-3.5" : "min-w-64 gap-3.5 p-4",
|
||||
onClick && "cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
active && "ring-2 ring-primary hover:ring-primary",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
{hasDelta && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
isGood ? "text-success" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-primary/10",
|
||||
size === "sm" ? "size-9" : "size-9"
|
||||
)}
|
||||
>
|
||||
<Icon className={size === "sm" ? "size-4 text-primary" : "size-4 text-primary"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className={cn("truncate font-medium text-muted-foreground", size === "sm" ? "text-sm" : "text-sm")}>{label}</p>
|
||||
<p className={cn("font-bold tracking-tight text-foreground", size === "sm" ? "text-xl" : "text-xl")}>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
|
||||
{hasDelta ? (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
</div>
|
||||
) : (
|
||||
caption && <p className="text-xs text-muted-foreground">{caption}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user