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:
+10
-2
@@ -39,12 +39,12 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
## 3. Procurement screens
|
||||
- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01
|
||||
- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07
|
||||
- [~] Purchase Order (`.../purchase-orders` list, `/new` create — **Save as draft** or **Create & submit** (auto-approve), prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: **Draft** is editable (ETag/If-Match) with **Submit** + **Delete**; a submitted/open PO is read-only with **Cancel** (with reason)) — FR-PROC-03..07. **2026-07-20:** rewired to the draft lifecycle — `isPoEditable` is now `Draft`-only, `submit`/`remove` added to `lib/api/purchase-orders.ts`, `saveAsDraft` on the create request. See the 2026-07-20 entry.
|
||||
- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page
|
||||
|
||||
## 4. Receiving screens
|
||||
- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail
|
||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`
|
||||
- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry.
|
||||
- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session
|
||||
- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed`
|
||||
- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn`
|
||||
@@ -90,6 +90,14 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass)
|
||||
- **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived).
|
||||
- **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages.
|
||||
- **Deliberately not touched:** the item picker already showed `sku — name` (the request's stated need); multi-GRN-per-PO and adding a non-PO item to a PO GRN already worked. Vendor stays PO-derived (non-selectable) for a PO-based GRN — selecting a different vendor than the PO's would be wrong.
|
||||
- **Select trigger showed the id, not the label (global fix).** Base UI's `Select.Value` renders the raw selected value unless the `Select.Root` is given an `items` map — the popup items unmount when closed, so their text isn't available to the trigger (confirmed in `@base-ui/react`'s `resolveSelectedLabel`, which `find`s `items` by value and only falls back to stringifying the value when none is supplied). Fixed once in the shared wrapper (`components/ui/select.tsx`): `Select` now walks its own `SelectItem` children and derives the `items` array automatically, so all ~60 `<SelectValue>` call sites across 26 files show the selected label without any per-site change. `tsc`/`eslint` clean; verified against Base UI's label-resolution source.
|
||||
- **Procurement sidebar submenu.** The sidebar builds submenus from backend-seeded `SubNavItem` rows filtered by `GET /auth/me`'s `navCodes`; only Products and Settings had children, so Purchase Orders had no sidebar section (only reachable via the Procurement hub card). Added a `children` array to the Procurement nav entry (`components/Layouts/AppSidebar.tsx`) — Requisitions, RFQs, Purchase Orders, Purchase Returns — matching new backend sub-nav codes. Also found the Admin role (`RoleId 2`) was never granted `NAV:procurement` at all, so the whole Procurement branch was hidden for it; granted the parent + 4 children. **Verified:** `/auth/me` for Admin now returns all five procurement codes → submenu renders. Stale PO hub-card copy ("freely editable while open") updated to the draft/submit wording.
|
||||
- **Verified:** `tsc --noEmit` clean; `eslint` unchanged from baseline (7 pre-existing `set-state-in-effect` on the PO/GRN screens before and after — 0 new issues, confirmed by stashing and re-counting). Runtime browser verification is the next step in this pass.
|
||||
|
||||
### 2026-07-17 — connected to the real API (mock-data.ts deleted)
|
||||
|
||||
**The app now talks to ERPCore.** Every `lib/api/*.ts` module calls the backend; `lib/api/mock-data.ts` is gone. This is the pass the 2026-07-15 note anticipated.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 0–100%"
|
||||
|
||||
const vatPct = Number(input.vatPct)
|
||||
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
|
||||
errors.vatPct = "VAT must be 0–100%"
|
||||
|
||||
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
|
||||
errors.batchNo = "Batch number is required for this item"
|
||||
}
|
||||
|
||||
@@ -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 % (0–100). Reduces inventory cost. */
|
||||
discountPct?: number
|
||||
/** VAT % (0–100). 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user