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,36 @@
// Gathers everything a purchase-order PDF needs for ONE PO id and downloads it. Shared by the PO
// table's row action and the PO detail page, so both produce the same document. The list endpoint
// carries no lines, so the PO is always re-read in full here.
import { companyProfile } from "@/lib/api/company-profile"
import { itemsApi } from "@/lib/api/items"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { downloadPurchaseOrderPdf } from "@/lib/pdf/purchase-order-pdf"
import { ItemListItem } from "@/types/master-data"
import { ProcurementConfig } from "@/types/procurement"
export async function downloadPurchaseOrderPdfById(poId: number, config: ProcurementConfig): Promise<void> {
const [po, itemPage, uomPage, warehousePage, company] = await Promise.all([
purchaseOrdersApi.get(poId),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
companyProfile().catch(() => null),
])
// The item list is capped, so any line item that is not in it is fetched on its own.
const items: ItemListItem[] = [...itemPage.items]
const missing = [...new Set(po.lines.map((l) => l.itemId))].filter((id) => !items.some((i) => i.itemId === id))
const fetched = await Promise.all(missing.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null)))
for (const item of fetched) if (item) items.push(item as unknown as ItemListItem)
await downloadPurchaseOrderPdf({
po,
items,
uoms: uomPage.items,
warehouses: warehousePage.items,
config,
company,
})
}
@@ -0,0 +1,259 @@
// Purchase-order PDF — built in the browser with jsPDF (the backend has no PDF library), in the same
// hand-drawn style as lib/pdf/day-end-report-pdf.ts (no jspdf-autotable dependency). One PDF per PO,
// with the vendor block, the item lines and the totals. The vendor contact / address and the tax
// columns appear only when Procurement configuration has them switched on.
import type { jsPDF as JsPdf } from "jspdf"
import { formatAmount, formatReportDate } from "@/lib/format"
import { rateToPct } from "@/lib/po-tax"
import { baseUomLabel } from "@/lib/uom-label"
import { CompanyProfile } from "@/types/company-profile"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ProcurementConfig, PurchaseOrder } from "@/types/procurement"
const PAGE = { width: 210, height: 297, margin: 14 }
const BOTTOM = PAGE.height - 16
const CONTENT_W = PAGE.width - PAGE.margin * 2
type Column = { header: string; width: number; align?: "left" | "right" }
export interface PurchaseOrderPdfInput {
po: PurchaseOrder
items: readonly ItemListItem[]
uoms: readonly Uom[]
warehouses: readonly Warehouse[]
config: ProcurementConfig
company: CompanyProfile | null
}
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
function fit(doc: JsPdf, text: string, maxWidth: number): string {
if (doc.getTextWidth(text) <= maxWidth) return text
let cut = text
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
return `${cut}...`
}
function statusLabel(status: string): string {
return status.replace(/([a-z])([A-Z])/g, "$1 $2")
}
/** Company block (left) and document title block (right); returns the y where content starts. */
function header(doc: JsPdf, po: PurchaseOrder, company: CompanyProfile | null): number {
const name = company?.tradeName?.trim() || company?.legalName?.trim() || ""
doc.setFont("helvetica", "bold")
doc.setFontSize(15)
doc.text(name || "Purchase Order", PAGE.margin, 18)
doc.setFont("helvetica", "normal")
doc.setFontSize(9)
doc.setTextColor(90)
let y = 23
const companyLines = [
company?.legalName && company.legalName !== name ? company.legalName : null,
[company?.addressLine1, company?.addressLine2].filter(Boolean).join(", "),
[company?.city, company?.country].filter(Boolean).join(", "),
[company?.phone, company?.email].filter(Boolean).join(" | "),
company?.taxRegistrationNo ? `Tax reg. ${company.taxRegistrationNo}` : null,
].filter((l): l is string => !!l && l.trim().length > 0)
for (const line of companyLines) {
doc.text(fit(doc, line, 105), PAGE.margin, y)
y += 4.2
}
doc.setTextColor(0)
doc.setFont("helvetica", "bold")
doc.setFontSize(18)
doc.text("PURCHASE ORDER", PAGE.width - PAGE.margin, 18, { align: "right" })
doc.setFontSize(10.5)
doc.text(po.docNo, PAGE.width - PAGE.margin, 25, { align: "right" })
doc.setFont("helvetica", "normal")
doc.setFontSize(9)
doc.setTextColor(90)
doc.text(`Status: ${statusLabel(po.status)}`, PAGE.width - PAGE.margin, 30, { align: "right" })
doc.text(`Date: ${formatReportDate(po.createdAt)}`, PAGE.width - PAGE.margin, 34.5, { align: "right" })
doc.setTextColor(0)
const bottom = Math.max(y, 38) + 2
doc.setDrawColor(200)
doc.line(PAGE.margin, bottom, PAGE.width - PAGE.margin, bottom)
return bottom + 7
}
/** "Vendor" box: name, code, and the contact / address the configuration enables. */
function vendorBlock(doc: JsPdf, y: number, po: PurchaseOrder, config: ProcurementConfig): number {
const v = po.vendor
doc.setFont("helvetica", "bold")
doc.setFontSize(10)
doc.text("Vendor", PAGE.margin, y)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
y += 5.5
if (!v) {
doc.text(`Vendor #${po.vendorId}`, PAGE.margin, y)
return y + 8
}
doc.setFont("helvetica", "bold")
doc.text(fit(doc, v.name, CONTENT_W), PAGE.margin, y)
doc.setFont("helvetica", "normal")
y += 5
doc.setFontSize(9)
doc.setTextColor(70)
const rows: string[] = [`Code: ${v.code}`]
if (config.vendorContactMode === "Mobile" && v.mobile) rows.push(`Mobile: ${v.mobile}`)
if (config.vendorContactMode === "Email" && v.email) rows.push(`Email: ${v.email}`)
for (const row of rows) {
doc.text(fit(doc, row, CONTENT_W), PAGE.margin, y)
y += 4.4
}
if (config.vendorAddressEnabled && v.address) {
const wrapped = doc.splitTextToSize(`Address: ${v.address}`, CONTENT_W) as string[]
for (const row of wrapped) {
doc.text(row, PAGE.margin, y)
y += 4.4
}
}
doc.setTextColor(0)
return y + 4
}
/** A table that breaks across pages, repeating its header. */
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][]): number {
const rowH = 6.5
const drawRow = (top: number, cells: string[]) => {
doc.setFontSize(8.5)
let x = PAGE.margin
columns.forEach((col, i) => {
const text = fit(doc, cells[i] ?? "", col.width - 3)
if (col.align === "right") doc.text(text, x + col.width - 1.5, top + 4.5, { align: "right" })
else doc.text(text, x + 1.5, top + 4.5)
x += col.width
})
}
const drawHeader = (top: number) => {
doc.setFillColor(238, 238, 236)
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
doc.setFont("helvetica", "bold")
drawRow(top, columns.map((c) => c.header))
doc.setFont("helvetica", "normal")
return top + rowH
}
let top = drawHeader(y)
rows.forEach((cells, idx) => {
if (top + rowH > BOTTOM) {
doc.addPage()
top = drawHeader(20)
}
if (idx % 2 === 1) {
doc.setFillColor(248, 248, 246)
doc.rect(PAGE.margin, top, CONTENT_W, rowH, "F")
}
drawRow(top, cells)
top += rowH
})
doc.setDrawColor(200)
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
return top + 5
}
function totalsBlock(doc: JsPdf, y: number, po: PurchaseOrder, showTax: boolean): number {
const rows: [string, string, boolean][] = [["Sub total", formatAmount(po.totals.subTotal), false]]
if (showTax) rows.push(["Tax", formatAmount(po.totals.tax), false])
rows.push([`Total (${po.totals.currency})`, formatAmount(po.totals.grandTotal), true])
const needed = rows.length * 6 + 4
if (y + needed > BOTTOM) {
doc.addPage()
y = 20
}
const labelX = PAGE.width - PAGE.margin - 70
const valueX = PAGE.width - PAGE.margin - 1.5
for (const [label, value, strong] of rows) {
if (strong) {
doc.setDrawColor(150)
doc.line(labelX, y - 3.8, PAGE.width - PAGE.margin, y - 3.8)
}
doc.setFont("helvetica", strong ? "bold" : "normal")
doc.setFontSize(strong ? 10.5 : 9.5)
doc.text(label, labelX, y)
doc.text(value, valueX, y, { align: "right" })
y += 6
}
doc.setFont("helvetica", "normal")
return y
}
function footer(doc: JsPdf, docNo: string) {
const pages = doc.getNumberOfPages()
for (let i = 1; i <= pages; i++) {
doc.setPage(i)
doc.setFontSize(8)
doc.setTextColor(120)
doc.text(`${docNo} — generated ${new Date().toLocaleString()}`, PAGE.margin, PAGE.height - 8)
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
doc.setTextColor(0)
}
}
/** Builds the PDF and triggers the browser download as `<doc no>.pdf`. */
export async function downloadPurchaseOrderPdf(input: PurchaseOrderPdfInput): Promise<void> {
const { po, items, uoms, warehouses, config, company } = input
const showTax = config.poTaxEnabled
const { jsPDF } = await import("jspdf")
const doc = new jsPDF({ unit: "mm", format: "a4" })
let y = header(doc, po, company)
y = vendorBlock(doc, y, po, config)
const columns: Column[] = showTax
? [
{ header: "#", width: 7 },
{ header: "SKU", width: 24 },
{ header: "Item", width: 40 },
{ header: "UOM", width: 14 },
{ header: "Warehouse", width: 15 },
{ header: "Qty", width: 13, align: "right" },
{ header: "Unit price", width: 19, align: "right" },
{ header: "Tax %", width: 11, align: "right" },
{ header: "Tax amt", width: 16, align: "right" },
{ header: "Line total", width: 23, align: "right" },
]
: [
{ header: "#", width: 7 },
{ header: "SKU", width: 26 },
{ header: "Item", width: 56 },
{ header: "UOM", width: 16 },
{ header: "Warehouse", width: 17 },
{ header: "Qty", width: 14, align: "right" },
{ header: "Unit price", width: 20, align: "right" },
{ header: "Line total", width: 26, align: "right" },
]
const rows = po.lines.map((line, i) => {
const item = items.find((it) => it.itemId === line.itemId)
const warehouse = warehouses.find((w) => w.warehouseId === line.warehouseId)
const net = line.qty * line.unitPrice
const base = [
String(i + 1),
item?.sku ?? `#${line.itemId}`,
item?.name ?? `Item #${line.itemId}`,
baseUomLabel(items, uoms, line.itemId),
warehouse?.code ?? `#${line.warehouseId}`,
String(line.qty),
formatAmount(line.unitPrice),
]
return showTax
? [...base, `${rateToPct(line.tax)}%`, formatAmount(line.taxAmount ?? 0), formatAmount(net + (line.taxAmount ?? 0))]
: [...base, formatAmount(net)]
})
y = table(doc, y, columns, rows)
totalsBlock(doc, y, po, showTax)
footer(doc, po.docNo)
doc.save(`${po.docNo}.pdf`)
}