Receive stock into a warehouse without a purchase order.
-
-
-
-
-
- )
-}
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx
new file mode 100644
index 0000000..0ee05bd
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/procurement/grn/new/page.tsx
@@ -0,0 +1,659 @@
+'use client'
+
+import { Fragment, useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { ArrowLeft01Icon, Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
+import { useFieldArray } from 'react-hook-form'
+import { useForm, zodResolver, z } from '@/lib/form'
+import { FormField } from '@/components/shared/form-field'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
+import { Separator } from '@/components/ui/separator'
+import { toast } from '@/components/ui/toast'
+import type { GrnItemBody } from '@/lib/api/procurement'
+import {
+ grn as grnApi,
+ purchaseOrders as purchaseOrdersApi,
+ warehouses as warehousesApi,
+ users as usersApi,
+ suppliers as suppliersApi,
+ items as itemsApi,
+ getApiError,
+ getPaginatedItems,
+} from '@/lib/api/index'
+import type {
+ PurchaseOrderResponse,
+ WarehouseResponse,
+ UserResponse,
+ SupplierResponse,
+ ItemSummaryResponse,
+} from '@/types/api'
+import { LineTracking, trackingPayload, trackingProgress } from '../_components/grn-tracking'
+import {
+ DISCOUNT_OPTIONS,
+ VAT_OPTIONS,
+ GrnTotals,
+ NumberCell,
+ PercentSelect,
+ batchSummary,
+ lineAmounts,
+ money,
+} from '../_components/grn-costing'
+
+const NO_SUPPLIER = 'none'
+
+type GrnMode = 'po' | 'direct'
+
+const percent = z.number().min(0, 'Min 0').max(100, 'Max 100')
+
+const lineSchema = z.object({
+ // Set when the line comes from a purchase order; empty on a direct line.
+ purchaseOrderItemId: z.string(),
+ itemId: z.string().min(1, 'Select an item'),
+ acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
+ rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
+ freeQuantity: z.number().min(0, 'Must be 0 or more'),
+ unitCost: z.number().min(0, 'Must be 0 or more'),
+ discountPercent: percent,
+ vatPercent: percent,
+ // One serial number per line (newline or comma separated) — used for serial-tracked items.
+ serialText: z.string(),
+ // Used for batch- and/or expiry-tracked items.
+ batches: z.array(z.object({ batchNumber: z.string(), expiryDate: z.string(), quantity: z.number() })),
+})
+
+const schema = z.object({
+ warehouseId: z.string().min(1, 'Warehouse is required'),
+ supplierId: z.string(),
+ receivedAt: z.string().min(1, 'Received date is required'),
+ receivedByUserId: z.string().min(1, 'Received by is required'),
+ discountPercent: percent,
+ vatPercent: percent,
+ items: z.array(lineSchema).min(1, 'Add at least one item'),
+})
+
+type FormValues = z.infer
+
+const emptyLine = {
+ purchaseOrderItemId: '',
+ itemId: '',
+ acceptedQuantity: 1,
+ rejectedQuantity: 0,
+ freeQuantity: 0,
+ unitCost: 0,
+ discountPercent: 0,
+ vatPercent: 0,
+ serialText: '',
+ batches: [],
+}
+
+function todayIso() {
+ return new Date().toISOString().slice(0, 10)
+}
+
+/**
+ * One GRN form for both ways of receiving goods. "Against PO" loads the order's lines and lets more
+ * lines be added from that order's items (e.g. a second batch); "Direct GRN" takes any catalogue item.
+ */
+export default function NewGrnPage() {
+ const router = useRouter()
+ const goBack = () => router.push('/procurement/grn')
+ const [warehousesList, setWarehousesList] = useState([])
+ const [usersList, setUsersList] = useState([])
+ const [suppliersList, setSuppliersList] = useState([])
+ const [itemsList, setItemsList] = useState([])
+ const [eligiblePOs, setEligiblePOs] = useState([])
+ const [mode, setMode] = useState('po')
+ const [poId, setPoId] = useState('')
+ const [selectedPO, setSelectedPO] = useState(null)
+ const [loadingPO, setLoadingPO] = useState(false)
+ // Tracking flags for items loaded from a PO, keyed by itemId (the PO line response doesn't carry them).
+ const [poItemInfo, setPoItemInfo] = useState>({})
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ warehouseId: '',
+ supplierId: NO_SUPPLIER,
+ receivedAt: todayIso(),
+ receivedByUserId: '',
+ discountPercent: 0,
+ vatPercent: 0,
+ items: [{ ...emptyLine }],
+ },
+ })
+
+ const { fields, append, remove, replace } = useFieldArray({ control: form.control, name: 'items' })
+
+ useEffect(() => {
+ Promise.all([
+ warehousesApi.list({ pageSize: 200 }),
+ usersApi.list({ pageSize: 200 }),
+ suppliersApi.list(),
+ itemsApi.list({ pageSize: 200 }),
+ purchaseOrdersApi.list({ pageSize: 200 }),
+ ])
+ .then(([ws, us, ss, its, pos]) => {
+ setWarehousesList(getPaginatedItems(ws))
+ setUsersList(getPaginatedItems(us))
+ setSuppliersList(ss)
+ setItemsList(getPaginatedItems(its))
+ // Only POs that can still receive goods
+ const all = getPaginatedItems(pos) as PurchaseOrderResponse[]
+ setEligiblePOs(all.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
+ })
+ .catch(err =>
+ toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
+ )
+ }, [])
+
+ const isPoMode = mode === 'po'
+ const infoFor = (itemId: string) => poItemInfo[itemId] ?? itemsList.find(i => i.id === itemId)
+ const poLines = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
+ const poLine = (poItemId: string) => poLines.find(i => i.id === poItemId)
+
+ const watchedItems = form.watch('items')
+ const headerDiscount = form.watch('discountPercent')
+ const headerVat = form.watch('vatPercent')
+ // Block Save until every tracked line has its serials/batches fully entered (accepted + free units).
+ const trackingIncomplete = watchedItems.some(v => {
+ const p = trackingProgress(infoFor(v.itemId), v.acceptedQuantity + v.freeQuantity, v.serialText, v.batches)
+ return p !== null && !p.complete
+ })
+
+ // The GRN-wide percentage is a default: choosing one fills every line, and a line can still be edited.
+ function applyToAllLines(field: 'discountPercent' | 'vatPercent', value: number) {
+ form.setValue(field, value, { shouldDirty: true })
+ fields.forEach((_, i) => form.setValue(`items.${i}.${field}`, value, { shouldDirty: true }))
+ }
+
+ function changeMode(next: GrnMode) {
+ if (next === mode) return
+ setMode(next)
+ setPoId('')
+ setSelectedPO(null)
+ const discountPercent = form.getValues('discountPercent')
+ const vatPercent = form.getValues('vatPercent')
+ // Direct starts with one blank line; Against PO stays empty until an order is picked.
+ form.setValue('supplierId', NO_SUPPLIER)
+ replace(next === 'direct' ? [{ ...emptyLine, discountPercent, vatPercent }] : [])
+ }
+
+ // A line in PO mode points at one of the order's lines; re-picking resets the cost and tracking.
+ function selectPoLine(index: number, poItemId: string) {
+ const line = poLine(poItemId)
+ if (!line) return
+ form.setValue(`items.${index}.purchaseOrderItemId`, poItemId, { shouldValidate: true })
+ form.setValue(`items.${index}.itemId`, line.itemId, { shouldValidate: true })
+ form.setValue(`items.${index}.unitCost`, line.unitCost)
+ form.setValue(`items.${index}.serialText`, '')
+ form.setValue(`items.${index}.batches`, [])
+ }
+
+ async function handlePOChange(nextId: string) {
+ setPoId(nextId)
+ const discountPercent = form.getValues('discountPercent')
+ const vatPercent = form.getValues('vatPercent')
+
+ setLoadingPO(true)
+ try {
+ const po = await purchaseOrdersApi.get(nextId)
+ const activeLines = (po.items ?? []).filter(item => !item.isCancelled)
+
+ const itemIds = [...new Set(activeLines.map(l => l.itemId))]
+ const details = await Promise.all(itemIds.map(id => itemsApi.get(id)))
+ setPoItemInfo(Object.fromEntries(details.map(d => [d.id, d])))
+
+ setSelectedPO(po)
+ form.setValue('supplierId', po.supplierId)
+ // Pre-fill each non-cancelled line with its remaining quantity and the PO's unit cost.
+ replace(
+ activeLines.map(item => ({
+ ...emptyLine,
+ purchaseOrderItemId: item.id,
+ itemId: item.itemId,
+ acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
+ unitCost: item.unitCost,
+ discountPercent,
+ vatPercent,
+ })),
+ )
+ } catch (err) {
+ toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
+ setPoId('')
+ setSelectedPO(null)
+ replace([])
+ } finally {
+ setLoadingPO(false)
+ }
+ }
+
+ async function onSubmit(values: FormValues) {
+ if (isPoMode && !selectedPO) {
+ toast.add({ title: 'Select a purchase order.', type: 'error' })
+ return
+ }
+ const items: GrnItemBody[] = []
+ for (const v of values.items) {
+ const info = infoFor(v.itemId)
+ const label = info?.sku ?? 'Line'
+ if (isPoMode && !v.purchaseOrderItemId) {
+ toast.add({ title: 'Select an item for every line.', type: 'error' })
+ return
+ }
+ if (v.acceptedQuantity + v.rejectedQuantity + v.freeQuantity <= 0) {
+ toast.add({ title: `${label}: enter a quantity.`, type: 'error' })
+ return
+ }
+ const payload = trackingPayload(info, { ...v, acceptedQuantity: v.acceptedQuantity + v.freeQuantity })
+ if ('error' in payload) {
+ toast.add({ title: `${label}: ${payload.error}`, type: 'error' })
+ return
+ }
+ items.push({
+ ...(isPoMode ? { purchaseOrderItemId: v.purchaseOrderItemId } : { itemId: v.itemId }),
+ acceptedQuantity: v.acceptedQuantity,
+ rejectedQuantity: v.rejectedQuantity,
+ freeQuantity: v.freeQuantity,
+ unitCost: v.unitCost,
+ discountPercent: v.discountPercent,
+ vatPercent: v.vatPercent,
+ ...payload,
+ })
+ }
+
+ try {
+ if (isPoMode && selectedPO) {
+ await grnApi.create({
+ purchaseOrderId: selectedPO.id,
+ warehouseId: values.warehouseId,
+ receivedAt: `${values.receivedAt}T00:00:00.000Z`,
+ items,
+ discountPercent: values.discountPercent,
+ vatPercent: values.vatPercent,
+ receivedByUserId: values.receivedByUserId,
+ })
+ } else {
+ await grnApi.createDirect({
+ warehouseId: values.warehouseId,
+ supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
+ receivedByUserId: values.receivedByUserId,
+ discountPercent: values.discountPercent,
+ vatPercent: values.vatPercent,
+ items,
+ })
+ }
+ toast.add({ title: 'Goods receipt recorded', type: 'success' })
+ router.push('/procurement/grn')
+ } catch (err) {
+ toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
+ }
+ }
+
+ return (
+
+
+
+
+
New GRN
+
+ Receive stock into a warehouse. Pick a purchase order to receive against it, or leave it as direct.
+
+
+
+
+
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/po/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/grn/po/new/page.tsx
deleted file mode 100644
index 4e07265..0000000
--- a/distribution_fe/app/(dashboard)/procurement/grn/po/new/page.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { useRouter } from 'next/navigation'
-import { HugeiconsIcon } from '@hugeicons/react'
-import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
-import { useFieldArray } from 'react-hook-form'
-import { useForm, zodResolver, z } from '@/lib/form'
-import { FormField } from '@/components/shared/form-field'
-import { Input } from '@/components/ui/input'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Button } from '@/components/ui/button'
-import { FieldGroup } from '@/components/ui/field'
-import { Separator } from '@/components/ui/separator'
-import { toast } from '@/components/ui/toast'
-import type { GrnItemBody } from '@/lib/api/procurement'
-import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
-import {
- grn as grnApi,
- purchaseOrders as purchaseOrdersApi,
- warehouses as warehousesApi,
- users as usersApi,
- items as itemsApi,
- getApiError,
- getPaginatedItems,
-} from '@/lib/api/index'
-import type {
- PurchaseOrderResponse,
- WarehouseResponse,
- UserResponse,
- ItemSummaryResponse,
-} from '@/types/api'
-
-const batchLineSchema = z.object({
- batchNumber: z.string(),
- expiryDate: z.string(),
- quantity: z.number(),
-})
-
-const grnItemSchema = z.object({
- purchaseOrderItemId: z.string().min(1),
- acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
- rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
- // One serial number per line (newline or comma separated) — used for serial-tracked items.
- serialText: z.string(),
- // Used for batch- and/or expiry-tracked items.
- batches: z.array(batchLineSchema),
-})
-
-const schema = z.object({
- purchaseOrderId: z.string().min(1, 'Purchase order is required'),
- warehouseId: z.string().min(1, 'Warehouse is required'),
- receivedAt: z.string().min(1, 'Received date is required'),
- receivedByUserId: z.string().min(1, 'Received by is required'),
- items: z.array(grnItemSchema).min(1, 'PO must have at least one line item'),
-})
-
-type FormValues = z.infer
-
-function todayIso() {
- return new Date().toISOString().slice(0, 10)
-}
-
-export default function NewPoGrnPage() {
- const router = useRouter()
- const goBack = () => router.push('/procurement/grn')
- const [eligiblePOs, setEligiblePOs] = useState([])
- const [warehousesList, setWarehousesList] = useState([])
- const [usersList, setUsersList] = useState([])
- const [selectedPO, setSelectedPO] = useState(null)
- const [loadingPO, setLoadingPO] = useState(false)
- // Tracking flags per catalogue item, keyed by itemId — the PO line response doesn't carry them.
- const [itemInfo, setItemInfo] = useState>({})
-
- const form = useForm({
- resolver: zodResolver(schema),
- defaultValues: {
- purchaseOrderId: '',
- warehouseId: '',
- receivedAt: todayIso(),
- receivedByUserId: '',
- items: [],
- },
- })
-
- const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
-
- const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
-
- // Block Save until every tracked line has its serials/batches fully entered.
- const watchedItems = form.watch('items')
- const trackingIncomplete = watchedItems.some((v, i) => {
- const info = poItems[i] ? itemInfo[poItems[i].itemId] : undefined
- const p = trackingProgress(info, v.acceptedQuantity, v.serialText, v.batches)
- return p !== null && !p.complete
- })
-
- // Load reference data on mount
- useEffect(() => {
- Promise.all([
- purchaseOrdersApi.list({ pageSize: 200 }),
- warehousesApi.list({ pageSize: 200 }),
- usersApi.list({ pageSize: 200 }),
- ])
- .then(([pos, ws, usersRes]) => {
- // Only POs that can still receive goods
- const pos_ = getPaginatedItems(pos) as PurchaseOrderResponse[]
- setEligiblePOs(pos_.filter(po => po.status === 'Issued' || po.status === 'PartiallyReceived'))
- setWarehousesList(getPaginatedItems(ws))
- setUsersList(getPaginatedItems(usersRes))
- })
- .catch(err =>
- toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
- )
- }, [])
-
- // When PO changes, fetch its details and populate items
- async function handlePOChange(poId: string) {
- form.setValue('purchaseOrderId', poId, { shouldValidate: true })
- if (!poId) {
- setSelectedPO(null)
- replace([])
- return
- }
- setLoadingPO(true)
- try {
- const po = await purchaseOrdersApi.get(poId)
- const activeLines = (po.items ?? []).filter(item => !item.isCancelled)
-
- const itemIds = [...new Set(activeLines.map(l => l.itemId))]
- const details = await Promise.all(itemIds.map(id => itemsApi.get(id)))
- setItemInfo(Object.fromEntries(details.map(d => [d.id, d])))
-
- setSelectedPO(po)
- // Pre-fill items: remaining quantity for each non-cancelled line
- const lineItems = activeLines.map(item => ({
- purchaseOrderItemId: item.id,
- acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
- rejectedQuantity: 0,
- serialText: '',
- batches: [],
- }))
- replace(lineItems)
- } catch (err) {
- toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' })
- } finally {
- setLoadingPO(false)
- }
- }
-
- function buildItems(values: FormValues, lines: typeof poItems): GrnItemBody[] | null {
- const result: GrnItemBody[] = []
- for (let i = 0; i < values.items.length; i++) {
- const v = values.items[i]
- const line = lines[i]
- const payload = trackingPayload(line ? itemInfo[line.itemId] : undefined, v)
- if ('error' in payload) {
- toast.add({ title: `${line?.sku ?? `line ${i + 1}`}: ${payload.error}`, type: 'error' })
- return null
- }
- result.push({
- purchaseOrderItemId: v.purchaseOrderItemId,
- acceptedQuantity: v.acceptedQuantity,
- rejectedQuantity: v.rejectedQuantity,
- ...payload,
- })
- }
- return result
- }
-
- async function onSubmit(values: FormValues) {
- const items = buildItems(values, poItems)
- if (!items) return
- try {
- await grnApi.create({
- purchaseOrderId: values.purchaseOrderId,
- warehouseId: values.warehouseId,
- receivedAt: `${values.receivedAt}T00:00:00.000Z`,
- items,
- receivedByUserId: values.receivedByUserId,
- })
- toast.add({ title: 'Goods receipt recorded', type: 'success' })
- router.push('/procurement/grn')
- } catch (err) {
- toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
- }
- }
-
- return (
-
-
-
-
-
Receipt Against PO
-
Record stock received into a warehouse against an issued purchase order.
-
-
-
-
-
- )
-}
diff --git a/distribution_fe/lib/api/procurement.ts b/distribution_fe/lib/api/procurement.ts
index db143c3..560ff38 100644
--- a/distribution_fe/lib/api/procurement.ts
+++ b/distribution_fe/lib/api/procurement.ts
@@ -77,6 +77,13 @@ export interface GrnItemBody {
batches?: GrnBatchLineBody[]
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
serialNumbers?: string[]
+ /** Cost per base-UOM unit. Omitted on a PO line falls back to the PO line's cost. */
+ unitCost?: number
+ /** Omitted falls back to the GRN-wide percentage. */
+ discountPercent?: number
+ vatPercent?: number
+ /** Free-issue units: stocked but not costed. Serials/batches must cover accepted + free. */
+ freeQuantity?: number
}
export interface GrnBody {
@@ -84,6 +91,9 @@ export interface GrnBody {
warehouseId: string
receivedAt: string
items: GrnItemBody[]
+ /** GRN-wide defaults for lines that don't set their own. */
+ discountPercent?: number
+ vatPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
}
@@ -92,6 +102,8 @@ export interface DirectGrnBody {
warehouseId: string
supplierId?: string
items: GrnItemBody[]
+ discountPercent?: number
+ vatPercent?: number
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
}
diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts
index c027417..90c0766 100644
--- a/distribution_fe/types/api.ts
+++ b/distribution_fe/types/api.ts
@@ -418,6 +418,17 @@ export interface GrnItemResponse {
rejectedQuantity: number
batches: GrnBatchLineResponse[]
serialNumbers: string[]
+ unitCost: number
+ discountPercent: number
+ vatPercent: number
+ /** Accepted qty x unit cost, before discount. */
+ grossAmount: number
+ discountAmount: number
+ vatAmount: number
+ /** Gross - discount + VAT. */
+ lineTotal: number
+ /** Free-issue units received at no charge (in stock, not costed). */
+ freeQuantity: number
}
export interface GrnResponse {
@@ -432,6 +443,14 @@ export interface GrnResponse {
receivedAt: string
createdAt: string
items: GrnItemResponse[]
+ discountPercent: number
+ vatPercent: number
+ subTotal: number
+ discountAmount: number
+ /** Subtotal - discount; the base VAT is charged on. */
+ taxableAmount: number
+ vatAmount: number
+ totalAmount: number
}
export interface PurchaseReturnItemResponse {