feat(procurement): enhance purchase order and GRN functionalities

- Updated purchase order descriptions for clarity on draft and submission processes.
- Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively.
- Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting.
- Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges.
- Updated API to support new functionalities, including submitting and deleting purchase orders.
- Improved UI components for better user experience in managing purchase orders and GRNs.
- Documented changes in security and backend phase documentation to reflect new processes and requirements.
This commit is contained in:
2026-07-21 10:08:32 +05:30
parent fe9e8a780f
commit f02c89b3cb
28 changed files with 594 additions and 66 deletions
@@ -18,7 +18,7 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
},
{
title: "Purchase Orders",
description: "Auto-approved on creation, freely editable while open, cancellable before receipt.",
description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.",
href: "/dashboard/procurement/purchase-orders",
icon: ShoppingCart,
},
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -65,6 +65,8 @@ export default function PurchaseOrderDetailPage() {
const [showCancelForm, setShowCancelForm] = useState(false)
const [cancelReason, setCancelReason] = useState("")
const [cancelling, setCancelling] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [deleting, setDeleting] = useState(false)
function toDraftLines(order: PurchaseOrder): DraftLine[] {
return order.lines.map((l) => ({
@@ -185,6 +187,39 @@ export default function PurchaseOrderDetailPage() {
}
}
async function handleSubmitPo() {
if (!po) return
setSaveError(null)
setSubmitting(true)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Purchase order submitted", `${updated.docNo} — now ${updated.status} and locked for editing.`)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete() {
if (!po) return
if (!window.confirm(`Delete draft ${po.docNo}? This cannot be undone.`)) return
setSaveError(null)
setDeleting(true)
try {
await purchaseOrdersApi.remove(po.poId)
toast.success("Draft deleted", po.docNo)
router.push("/dashboard/procurement/purchase-orders")
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not delete purchase order", errorMessage(err))
setDeleting(false)
}
}
async function handleCancel() {
if (!po) return
if (!cancelReason.trim()) {
@@ -226,6 +261,9 @@ export default function PurchaseOrderDetailPage() {
const editable = isPoEditable(po.status) && !conflict
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0)
// A submitted-but-still-open PO (issued to the vendor) is cancellable with a reason;
// a Draft is deleted instead, and closed/cancelled POs are terminal.
const cancellable = po.status === "Approved" || po.status === "PartiallyReceived"
return (
<div className="flex flex-col gap-6">
@@ -245,18 +283,32 @@ export default function PurchaseOrderDetailPage() {
</div>
</div>
{isPoEditable(po.status) && !showCancelForm && (
<Button
variant="destructive"
size="lg"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
>
<Ban className="size-5" />
Cancel PO
</Button>
)}
<div className="flex items-center gap-3">
{po.status === "Draft" && (
<>
<Button variant="outline" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Send className="size-5" />
{submitting ? "Submitting…" : "Submit"}
</Button>
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
<Trash2 className="size-5" />
{deleting ? "Deleting…" : "Delete draft"}
</Button>
</>
)}
{cancellable && !showCancelForm && (
<Button
variant="destructive"
size="lg"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
>
<Ban className="size-5" />
Cancel PO
</Button>
)}
</div>
</div>
{showCancelForm && (
@@ -151,7 +151,7 @@ function NewPurchaseOrderContent() {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
async function handleSubmit(saveAsDraft: boolean) {
setHeaderError(null)
setSubmitError(null)
@@ -197,8 +197,12 @@ function NewPurchaseOrderContent() {
vendorId,
requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines,
saveAsDraft,
})
toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`)
toast.success(
"Purchase order created",
saveAsDraft ? `${po.docNo} — saved as draft.` : `${po.docNo} — auto-approved (FR-PROC-04).`
)
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
} catch (err) {
setSubmitError(errorMessage(err))
@@ -394,8 +398,11 @@ function NewPurchaseOrderContent() {
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create PO"}
<Button size="lg" type="button" variant="outline" onClick={() => handleSubmit(true)} disabled={submitting}>
{submitting ? "Saving…" : "Save as draft"}
</Button>
<Button size="lg" type="button" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? "Creating…" : "Create & submit"}
</Button>
</div>
</>
@@ -158,6 +158,7 @@ export default function GrnDetailPage() {
</div>
)}
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -165,8 +166,12 @@ export default function GrnDetailPage() {
<TableHead className="h-12 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Received value</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Disc %</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Net cost</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Received value</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">VAT</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
</TableRow>
@@ -180,8 +185,22 @@ export default function GrnDetailPage() {
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{line.unitCost.toFixed(2)}
{line.poUnitPrice !== null && line.priceVariance !== 0 && (
<span className="block text-xs text-warning">
PO {line.poUnitPrice.toFixed(2)} · var {line.priceVariance > 0 ? "+" : ""}{line.priceVariance.toFixed(2)}
</span>
)}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.discountPct.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.netUnitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{line.receivedValue.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{line.vatAmount.toFixed(2)}
<span className="block text-xs text-muted-foreground">{line.vatPct.toFixed(2)}%</span>
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums font-medium">{line.lineTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
@@ -228,6 +247,22 @@ export default function GrnDetailPage() {
})}
</TableBody>
</Table>
</div>
<div className="flex justify-end gap-8 border-t border-border pt-4 text-base">
<div className="flex gap-3">
<span className="text-muted-foreground">Stock value (excl. VAT)</span>
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.receivedValue, 0).toFixed(2)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">VAT</span>
<span className="tabular-nums">{grn.lines.reduce((s, l) => s + l.vatAmount, 0).toFixed(2)}</span>
</div>
<div className="flex gap-3">
<span className="text-muted-foreground">Document total</span>
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
</div>
</div>
</div>
)
}
@@ -37,12 +37,28 @@ interface DraftLine {
binId: number | null
qty: string
unitCost: string
/** PO line price when prefilled from a PO; drives the variance hint. */
poUnitPrice: number | null
discountPct: string
vatPct: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
function computeLine(l: DraftLine) {
const qty = Number(l.qty) || 0
const gross = Number(l.unitCost) || 0
const disc = Number(l.discountPct) || 0
const vat = Number(l.vatPct) || 0
const netUnitCost = gross * (1 - disc / 100)
const receivedValue = qty * netUnitCost
const vatAmount = receivedValue * (vat / 100)
return { netUnitCost, receivedValue, vatAmount, lineTotal: receivedValue + vatAmount }
}
let keySeq = 0
function newKey() {
keySeq += 1
@@ -58,6 +74,9 @@ function emptyLine(): DraftLine {
binId: null,
qty: "",
unitCost: "",
poUnitPrice: null,
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
@@ -152,6 +171,9 @@ export default function NewGrnPage() {
binId: null,
qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice),
poUnitPrice: l.unitPrice,
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
@@ -211,6 +233,8 @@ export default function NewGrnPage() {
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
@@ -232,6 +256,8 @@ export default function NewGrnPage() {
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
@@ -371,16 +397,20 @@ export default function NewGrnPage() {
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -473,6 +503,50 @@ export default function NewGrnPage() {
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
<p className="mt-1 text-xs text-warning">
PO price {line.poUnitPrice.toFixed(2)} variance recorded
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
max="100"
step="any"
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
{(() => {
const c = computeLine(line)
return (
<div className="flex h-11 flex-col justify-center">
<span>{c.lineTotal.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
</span>
</div>
)
})()}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus>
@@ -533,6 +607,16 @@ export default function NewGrnPage() {
})}
</TableBody>
</Table>
</div>
)}
{!poLoading && lines.length > 0 && (
<div className="flex justify-end gap-6 pr-12 text-base">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="font-semibold tabular-nums">
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
</span>
</div>
)}
</div>
@@ -8,12 +8,14 @@ import {
Building2,
ChevronRight,
ClipboardList,
FileText,
HelpCircle,
LayoutGrid,
ListTree,
Menu,
Package,
PackageCheck,
PackageX,
Ruler,
Settings,
ShieldCheck,
@@ -59,7 +61,19 @@ const navItems: {
],
},
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
{ title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
{
title: "Procurement",
code: "procurement",
href: "/dashboard/procurement",
icon: ClipboardList,
chevron: true,
children: [
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
],
},
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
+34 -1
View File
@@ -6,7 +6,40 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
// Base UI's `Select.Value` renders the raw selected value (e.g. an id) unless the Root is
// given an `items` map to resolve the label from — the popup items are unmounted when closed,
// so their text isn't otherwise available. Rather than pass `items` at all ~60 call sites,
// this wrapper walks its own `SelectItem` children and derives that map automatically, so the
// trigger shows the selected item's label instead of its value.
function collectItems(
children: React.ReactNode,
acc: { value: unknown; label: React.ReactNode }[]
) {
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return
if (child.type === SelectItem) {
const p = child.props as { value?: unknown; children?: React.ReactNode }
acc.push({ value: p.value, label: p.children })
return
}
const nested = (child.props as { children?: React.ReactNode }).children
if (nested) collectItems(nested, acc)
})
}
function Select<Value, Multiple extends boolean | undefined = false>(
props: SelectPrimitive.Root.Props<Value, Multiple>
) {
const { items, children } = props
const derivedItems = React.useMemo(() => {
if (items) return items
const acc: { value: unknown; label: React.ReactNode }[] = []
collectItems(children, acc)
return acc.length ? (acc as ReadonlyArray<{ value: Value; label: React.ReactNode }>) : undefined
}, [items, children])
return <SelectPrimitive.Root {...props} items={derivedItems} />
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
+13 -3
View File
@@ -20,10 +20,10 @@ export interface ListPurchaseOrdersParams {
sort?: string
}
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */
/** Editable/deletable only while Draft (FR-PROC-05, revised — submitting locks the PO).
* The server is authoritative (409 PO_NOT_EDITABLE otherwise); this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
return status === "Draft"
}
export const purchaseOrdersApi = {
@@ -54,6 +54,16 @@ export const purchaseOrdersApi = {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
},
/** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */
submit(poId: number): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/submit`, { method: "POST" })
},
/** Delete a Draft PO. 409 PO_NOT_EDITABLE once submitted. */
remove(poId: number): Promise<void> {
return apiRequest<void>(`/purchase-orders/${poId}`, { method: "DELETE" })
},
/** 409 if any receipt exists against the PO. */
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
@@ -16,6 +16,8 @@ export function validateLine(input: {
uomId: number | null
qty: string
unitCost: string
discountPct: string
vatPct: string
trackingMode: TrackingMode | null
batchNo: string
serialNumbersText: string
@@ -31,6 +33,14 @@ export function validateLine(input: {
const unitCost = Number(input.unitCost)
if (input.unitCost === "" || Number.isNaN(unitCost) || unitCost < 0) errors.unitCost = "Unit cost cannot be negative"
const discountPct = Number(input.discountPct)
if (input.discountPct !== "" && (Number.isNaN(discountPct) || discountPct < 0 || discountPct > 100))
errors.discountPct = "Discount must be 0100%"
const vatPct = Number(input.vatPct)
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0100%"
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
errors.batchNo = "Batch number is required for this item"
}
+22
View File
@@ -29,7 +29,16 @@ export interface CreateGrnLineInput {
uomId: number
binId?: number | null
qty: number
/**
* Gross unit cost. For a PO line it is an optional per-receipt override — 0/omitted uses
* the PO price; a value wins and the server records a variance (docs/02-SECURITY C.3,
* revised). Required (> 0) for a direct receipt.
*/
unitCost: number
/** Trade discount % (0100). Reduces inventory cost. */
discountPct?: number
/** VAT % (0100). Recoverable — does not affect stock value. */
vatPct?: number
holdStatus: HoldStatus
batch?: BatchInput | null
}
@@ -49,8 +58,21 @@ export interface GrnLine {
uomId: number
binId: number | null
qty: number
/** Gross unit cost received at. */
unitCost: number
/** PO price snapshot at receipt; null for direct receipts. */
poUnitPrice: number | null
discountPct: number
/** After-discount cost — what the FIFO layer is valued at. */
netUnitCost: number
vatPct: number
vatAmount: number
/** qty × netUnitCost (after discount, before VAT). */
receivedValue: number
/** qty × netUnitCost + vatAmount — payable to vendor. */
lineTotal: number
/** (unitCost poUnitPrice) × qty; 0 for direct receipts. */
priceVariance: number
holdStatus: HoldStatus
batchId: number | null
}
+4 -2
View File
@@ -189,10 +189,12 @@ export interface CreatePurchaseOrderRequest {
vendorId: number
requisitionId?: number | null
lines: CreatePoLineInput[]
/** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */
saveAsDraft?: boolean
}
/** PUT /purchase-orders/{poId} — edit-while-open, same line shape as create (FR-PROC-05, Option B). */
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
/** PUT /purchase-orders/{poId} — edit a Draft only (FR-PROC-05, revised); same line shape as create. */
export type UpdatePurchaseOrderRequest = Omit<CreatePurchaseOrderRequest, "saveAsDraft">
export interface CancelPurchaseOrderRequest {
reason?: string | null