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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user