feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"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 { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
formatDate,
|
||||
InvoicePreviewFrame,
|
||||
money,
|
||||
PaperSize,
|
||||
printColorExact,
|
||||
qty,
|
||||
RECEIPT_SHEET_BASE_CLASS,
|
||||
RECEIPT_SHEET_CLASS,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
ReceiptWidth,
|
||||
SectionTitle,
|
||||
} from "@/components/invoice-template/InvoicePrintKit"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Grn } from "@/types/grn"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
/*
|
||||
* Printable goods-received note in the same three paper sizes as the invoices — A4, 80mm and 58mm receipt — sharing
|
||||
* their preview frame, PDF export and print plumbing. A draft prints with a DRAFT marker so it can't be mistaken for a
|
||||
* posted receipt.
|
||||
*/
|
||||
|
||||
interface GrnDoc {
|
||||
grn: Grn
|
||||
vendor: Vendor | null
|
||||
warehouse: Warehouse | null
|
||||
poDocNo: string | null
|
||||
company: CompanyProfile | null
|
||||
showLogo: boolean
|
||||
/** itemId -> "SKU — name" */
|
||||
itemLabels: Map<number, { sku: string; name: string }>
|
||||
}
|
||||
|
||||
const statusMark = (grn: Grn) => (grn.status === "Draft" ? "DRAFT" : null)
|
||||
|
||||
function A4Sheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels }: GrnDoc) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIVED 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">Date</dt>
|
||||
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Against</dt>
|
||||
<dd>{poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct receipt"}</dd>
|
||||
{statusMark(grn) && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{statusMark(grn)}</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>Received from</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
|
||||
{vendor?.code && <p>Vendor code: {vendor.code}</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.code} — ${warehouse.name}` : `Warehouse #${grn.warehouseId}`}</p>
|
||||
{warehouse?.address && <p>{warehouse.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Goods 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-right">Qty</th>
|
||||
<th className="px-2 py-2.5 text-right">Unit cost</th>
|
||||
<th className="px-2 py-2.5 text-right">Disc %</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((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<tr key={l.grnLineId} className="border-b border-neutral-300">
|
||||
<td className="px-2 py-2.5">
|
||||
<p className="font-medium text-neutral-900">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
{item?.sku && <p className="text-xs text-neutral-500">{item.sku}</p>}
|
||||
{l.batchNo && <p className="text-xs text-neutral-500">Batch {l.batchNo}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{qty(l.qty)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(l.unitCost)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.discountPct > 0 ? l.discountPct : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.vatAmount > 0 ? money(l.vatAmount) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(l.lineTotal)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end print:break-inside-avoid">
|
||||
<div className="w-72 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>Subtotal</span><span className="tabular-nums">{money(subtotal)}</span></div>
|
||||
{vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(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(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 gap-16 text-xs text-neutral-700 print:break-inside-avoid">
|
||||
<div className="border-t border-neutral-900 pt-1.5">Received by</div>
|
||||
<div className="border-t border-neutral-900 pt-1.5">Delivered by</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills. */
|
||||
function ReceiptSheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels, width }: GrnDoc & { width: ReceiptWidth }) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} 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 RECEIVED</p>
|
||||
{statusMark(grn) && <p className="font-bold">*** {statusMark(grn)} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="GRN No." value={grn.docNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
|
||||
<ReceiptRow label="Against" value={poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct"} />
|
||||
<ReceiptRow label="Vendor" value={vendor?.name ?? `#${grn.vendorId}`} />
|
||||
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{grn.lines.map((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<div key={l.grnLineId}>
|
||||
<p className="font-medium">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>{qty(l.qty)} x {money(l.unitCost)}</span>
|
||||
<span>{money(l.lineTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Subtotal" value={money(subtotal)} />
|
||||
{vat > 0 && <ReceiptRow label="VAT" value={money(vat)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(total)} bold />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the GRN plus the optional decoration around it (vendor, warehouse, PO, item names, company). Only the GRN is
|
||||
* required — any other failure just leaves that block 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, companyRes, templateRes, items] = 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),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
|
||||
])
|
||||
if (cancelled) return
|
||||
const company = companyRes.data
|
||||
const itemLabels = new Map<number, { sku: string; name: string }>()
|
||||
for (const item of items) if (item) itemLabels.set(item.itemId, { sku: item.sku, name: item.name })
|
||||
setDoc({
|
||||
grn,
|
||||
vendor: vendorRes?.data ?? null,
|
||||
warehouse,
|
||||
poDocNo: po?.docNo ?? null,
|
||||
company,
|
||||
showLogo: templateRes.data?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
itemLabels,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [grnId])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/** Popup print preview of a GRN — same frame as the invoice dialogs. Opens on A4 every time. */
|
||||
export function GrnReceiptDialog({
|
||||
grnId,
|
||||
docNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
grnId: number | null
|
||||
docNo?: string
|
||||
open: boolean
|
||||
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">Goods received note{docNo ? ` ${docNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{open && grnId !== null && <GrnReceiptPreview grnId={grnId} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function GrnReceiptPreview({ grnId, className }: { grnId: number; className?: string }) {
|
||||
const { doc, error } = useGrnDoc(grnId)
|
||||
return (
|
||||
<InvoicePreviewFrame
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
docNo={doc?.grn.docNo ?? `grn-${grnId}`}
|
||||
shareTitle={`GRN ${doc?.grn.docNo ?? ""}`.trim()}
|
||||
sharePath={`/dashboard/receiving/grn/${grnId}`}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user