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
+20 -2
View File
@@ -12,6 +12,9 @@ import { ApiResult, ProblemDetails } from "@/types/common"
const API_BASE = "/api/v1"
/** Fired on window whenever the API answers 403, so the auth context can re-read the person's permissions. */
export const FORBIDDEN_EVENT = "erp:forbidden"
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
export class ApiError extends Error {
status: number
@@ -36,7 +39,7 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
ifMatch?: string
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
idempotencyKey?: string
/** Sent as X-XSRF-TOKEN. Only the [ValidateCsrf] actions on AuthController need it (docs/11 §2.0). */
/** Sent as X-XSRF-TOKEN. Now sent automatically on every POST/PUT/PATCH/DELETE (the API checks all of them); kept so a caller can force it. */
csrf?: boolean
}
@@ -78,7 +81,11 @@ function redirectToLogin() {
async function rawRequest(path: string, options: RequestOptions = {}, isRetry = false): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
// The API double-submit-checks every state-changing request that rides on the session cookie, so send the token
// on all of them (it is just echoing a cookie of our own origin).
const method = (rest.method ?? "GET").toUpperCase()
const stateChanging = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"
const csrfToken = csrf || stateChanging ? readCsrfToken() : null
// Multipart uploads (attendance files, staff documents) pass a FormData body — the
// browser sets its own Content-Type (with boundary), and it must never be JSON-encoded.
const isFormData = typeof FormData !== "undefined" && body instanceof FormData
@@ -105,7 +112,18 @@ async function rawRequest(path: string, options: RequestOptions = {}, isRetry =
redirectToLogin()
}
// A session older than the CSRF check has no XSRF-TOKEN cookie (or it rotated): renewing the session re-issues it.
// Peek at a clone so the normal error path below still reads the body.
if (response.status === 403 && !isRetry && !path.startsWith("/auth/")) {
const peek = await response.clone().json().catch(() => null) as ProblemDetails | null
if (peek?.code === "CSRF_TOKEN_MISMATCH" && (await refreshSession())) return rawRequest(path, options, true)
}
if (!response.ok) {
if (response.status === 403 && typeof window !== "undefined") {
// The role may have changed under this tab: let the auth context re-read what the person can still do.
window.dispatchEvent(new CustomEvent(FORBIDDEN_EVENT))
}
let problem: ProblemDetails
try {
problem = (await response.json()) as ProblemDetails
@@ -0,0 +1,21 @@
// Access configuration. Singleton - no id. The default role is what a first sign-in (or a person with no role) receives.
import { apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
export interface AccessConfig {
defaultRoleId: number | null
defaultRoleName: string | null
updatedAt: string | null
updatedBy: number | null
}
export const accessConfigApi = {
get(): Promise<ApiResult<AccessConfig>> {
return apiRequestWithETag<AccessConfig>("/access-config")
},
/** `null` clears the default (people without a role then have no permissions at all). The administrator role is refused. */
update(defaultRoleId: number | null, ifMatch: string): Promise<ApiResult<AccessConfig>> {
return apiRequestWithETag<AccessConfig>("/access-config", { method: "PUT", body: { defaultRoleId }, ifMatch })
},
}
+5
View File
@@ -27,6 +27,11 @@ export interface ListGrnsParams {
vendorId?: number
warehouseId?: number
branchId?: number
/** Created on or after this day (YYYY-MM-DD). */
from?: string
/** Created on or before this day (YYYY-MM-DD). */
to?: string
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
sort?: string
}
@@ -0,0 +1,40 @@
// Opening balances. post() creates ONE journal in the General Ledger (a debit per cash/bank account, the
// offsetting credit to "Opening Balance Equity") and can be done only once: a second call is 409
// OPENING_BALANCE_ALREADY_POSTED.
import { apiRequest } from "@/lib/api-client"
import {
CashBankOpeningState,
OpeningBalanceRun,
OpeningStockRun,
OpeningStockRunSummary,
PostCashBankOpeningRequest,
PostOpeningStockRequest,
} from "@/types/opening-balances"
export const openingBalancesApi = {
/** The cash/bank accounts to enter amounts for — or the posted record, once it has been done. */
cashBank(): Promise<CashBankOpeningState> {
return apiRequest<CashBankOpeningState>("/opening-balances/cash-bank")
},
postCashBank(request: PostCashBankOpeningRequest): Promise<OpeningBalanceRun> {
return apiRequest<OpeningBalanceRun>("/opening-balances/cash-bank", { method: "POST", body: request })
},
/** Opening-stock runs posted so far, newest first. */
stockRuns(): Promise<OpeningStockRunSummary[]> {
return apiRequest<OpeningStockRunSummary[]>("/opening-balances/stock")
},
stockRun(runId: number): Promise<OpeningStockRun> {
return apiRequest<OpeningStockRun>(`/opening-balances/stock/${runId}`)
},
/**
* Creates a FIFO layer per line and ONE journal (Dr Inventory / Cr Opening Balance Equity). 409 when an item
* already has stock movements in that warehouse; untracked, stocked items only.
*/
postStock(request: PostOpeningStockRequest): Promise<OpeningStockRun> {
return apiRequest<OpeningStockRun>("/opening-balances/stock", { method: "POST", body: request })
},
}
@@ -0,0 +1,22 @@
// Procurement configuration. Singleton - no id. Controls the optional vendor contact / address
// fields and whether purchase orders carry tax. The server enforces every flag too: a value sent
// for a switched-off field returns 422 CONFIG_DISABLED, so the UI hides what is off.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { ProcurementConfig, UpdateProcurementConfigRequest } from "@/types/procurement"
export const procurementConfigApi = {
get(): Promise<ApiResult<ProcurementConfig>> {
return apiRequestWithETag<ProcurementConfig>("/procurement-config")
},
/** Full replacement — every flag is required. */
update(request: UpdateProcurementConfigRequest, ifMatch: string): Promise<ApiResult<ProcurementConfig>> {
return apiRequestWithETag<ProcurementConfig>("/procurement-config", { method: "PUT", body: request, ifMatch })
},
}
/** Read-only helper for screens that only need the flags. */
export function procurementConfig(): Promise<ProcurementConfig> {
return apiRequest<ProcurementConfig>("/procurement-config")
}
@@ -18,13 +18,18 @@ export interface ListPurchaseOrdersParams {
q?: string
status?: PurchaseOrderStatus
vendorId?: number
/** Created on or after this day (YYYY-MM-DD). */
from?: string
/** Created on or before this day (YYYY-MM-DD). */
to?: string
/** `created` (default, newest first) | `vendor` | `total` | `status`; a leading `-` flips the direction. */
sort?: string
}
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
/** Editable/deletable only while Draft or Rejected (FR-PROC-05, revised — submitting locks the PO; a rejected one is reopened for revision).
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status === "Draft"
return status === "Draft" || status === "Rejected"
}
export const purchaseOrdersApi = {
+2
View File
@@ -21,6 +21,8 @@ export interface ListUsersParams {
q?: string
status?: EntityStatus
roleCode?: string
/** true = only administrators (users whose role is the administrator role, whatever it is called). */
isAdmin?: boolean
}
export const usersApi = {
+42
View File
@@ -0,0 +1,42 @@
// Which permission opens which page: the route guard in components/auth/AccessGate.tsx uses this so a role
// can't open a screen by typing its address (the sidebar only hides the link). Derived from the sidebar's own
// list, so a page and its menu entry can never disagree; the API enforces the same codes on every request, so this
// is a courtesy that replaces a wall of "Forbidden" toasts with one clear message — never the protection.
import { navItems } from "@/components/Layouts/AppSidebar"
interface RouteRule {
prefix: string
/** Any one of these opens the page. */
codes: string[]
}
/** Pages with no menu entry of their own, but that belong to a permission anyway. */
const EXTRA_RULES: RouteRule[] = [
{ prefix: "/dashboard/settings/audit-logs", codes: ["settings.audit-logs"] },
]
function buildRules(): RouteRule[] {
const rules: RouteRule[] = [...EXTRA_RULES]
for (const item of navItems) {
// The landing page and Help are open to everyone with a role (they hold no data of their own).
if (item.code !== "dashboard" && item.code !== "help") rules.push({ prefix: item.href, codes: [item.code] })
for (const child of item.children ?? []) {
rules.push({ prefix: child.href, codes: [child.code, ...(child.altCodes ?? [])] })
}
}
// Longest prefix first, so "/dashboard/procurement/purchase-orders/approvals" beats ".../purchase-orders".
return rules.sort((a, b) => b.prefix.length - a.prefix.length)
}
let cached: RouteRule[] | null = null
/**
* The permission codes that open `pathname` (any one suffices), or null when the page is unguarded
* (the landing page, Help, and anything not in the menu: those are protected by the API alone).
*/
export function requiredCodesFor(pathname: string): string[] | null {
cached ??= buildRules()
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname
const rule = cached.find((r) => path === r.prefix || path.startsWith(r.prefix + "/"))
return rule ? rule.codes : null
}
@@ -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`)
}
+33
View File
@@ -0,0 +1,33 @@
// Purchase-order tax helpers. The UI works in percent (18 for 18%) because that is how an item's
// tax rate is stored (`taxRatePercentage`) and how people say it; the PO API carries the rate as a
// fraction 0..1 (0.18) and a stored amount. The server recomputes and stores the amount itself, so
// these figures are for display and live totals only.
/** 18 -> 0.18. Four decimals, the precision the API stores. */
export function pctToRate(pct: number): number {
if (!Number.isFinite(pct) || pct <= 0) return 0
return Math.round(pct * 100) / 10000
}
/** 0.18 -> 18. */
export function rateToPct(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 0
return Math.round(rate * 10000) / 100
}
/**
* Tax amount for a line: qty x unit price x pct%, rounded half-up to 2 dp like the server
* (`PurchaseOrderService.TaxAmountFor`). The epsilon keeps 1.005-style products from rounding down.
*/
export function lineTaxAmount(qty: number, unitPrice: number, pct: number): number {
const q = Number(qty)
const p = Number(unitPrice)
const t = Number(pct)
if (![q, p, t].every(Number.isFinite) || q <= 0 || p < 0 || t <= 0) return 0
return Math.round(q * p * t + 1e-9) / 100
}
/** The text of a percent for an input box: 18 -> "18", 8.25 -> "8.25", 0 -> "0". */
export function pctText(pct: number | null | undefined): string {
return pct === null || pct === undefined || !Number.isFinite(pct) ? "0" : String(pct)
}
@@ -46,6 +46,7 @@ export function validatePoLine(input: {
if (input.unitPrice === "" || Number.isNaN(unitPrice) || unitPrice < 0) errors.unitPrice = "Unit price cannot be negative"
const tax = Number(input.tax)
if (input.tax === "" || Number.isNaN(tax) || tax < 0) errors.tax = "Tax rate cannot be negative"
else if (tax > 1) errors.tax = "Tax cannot exceed 100%"
return errors
}