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,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`)
|
||||
}
|
||||
Reference in New Issue
Block a user