ui and bug fixers 29/09
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user