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:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -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}
/>
)
}