diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx index ca0259d..8789dde 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx @@ -124,16 +124,10 @@ export function GrnClient() { Stock received into a warehouse, against a purchase order or directly

-
- - -
+ Math.round(Number((n * 100).toPrecision(12))) / 100 + +export interface CostedLine { + acceptedQuantity: number + unitCost: number + discountPercent: number + vatPercent: number +} + +/** gross = qty x cost; discount on gross; VAT on (gross - discount); total = gross - discount + VAT. */ +export function lineAmounts(l: CostedLine) { + const gross = round2((l.acceptedQuantity || 0) * (l.unitCost || 0)) + const discount = round2((gross * (l.discountPercent || 0)) / 100) + const vat = round2(((gross - discount) * (l.vatPercent || 0)) / 100) + return { gross, discount, vat, total: gross - discount + vat } +} + +export function grnTotals(lines: CostedLine[]) { + const amounts = lines.map(lineAmounts) + const subTotal = amounts.reduce((s, a) => s + a.gross, 0) + const discount = amounts.reduce((s, a) => s + a.discount, 0) + const vat = amounts.reduce((s, a) => s + a.vat, 0) + return { subTotal, discount, taxable: subTotal - discount, vat, total: subTotal - discount + vat } +} + +export const money = (n: number) => + n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + +/** Text for the Batch No column: lot numbers for batch items, a count for serial/expiry items. */ +export function batchSummary( + info: ItemSummaryResponse | undefined, + batches: BatchRow[], + serialText: string, +): string { + if (!info) return '—' + if (info.tracksSerialNumbers) { + const n = serialText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean).length + return n === 0 ? 'Serials not entered' : `${n} serial${n === 1 ? '' : 's'}` + } + if (info.tracksBatches) { + const named = batches.filter(b => b.batchNumber.trim()) + return named.length === 0 ? 'Not entered' : named.map(b => `${b.batchNumber.trim()} ×${b.quantity}`).join(', ') + } + if (info.tracksExpiryDate) { + return batches.length === 0 ? 'Not entered' : `${batches.length} expiry lot${batches.length === 1 ? '' : 's'}` + } + return '—' +} + +interface PercentSelectProps { + label: string + value: number + options: number[] + onChange: (value: number) => void +} + +/** + * GRN-wide percentage: pick a preset from the list or type any value from 0 to 100. The text is kept + * locally while typing so "2." or "" don't get rewritten under the cursor; the number is only pushed + * up when it is valid. + */ +export function PercentSelect({ label, value, options, onChange }: PercentSelectProps) { + const listId = useId() + const [text, setText] = useState(String(value)) + + // Follow outside changes (e.g. a reset) without fighting what the user is typing. + useEffect(() => { + setText(prev => (Number(prev) === value && prev !== '' ? prev : String(value))) + }, [value]) + + function handleChange(raw: string) { + setText(raw) + const n = Number(raw) + if (raw.trim() !== '' && Number.isFinite(n) && n >= 0 && n <= 100) onChange(n) + } + + // An empty or out-of-range entry falls back to the last valid value on blur. + function handleBlur() { + const n = Number(text) + if (text.trim() === '' || !Number.isFinite(n) || n < 0 || n > 100) setText(String(value)) + } + + return ( + + {label} +
+ handleChange(e.target.value)} + onBlur={handleBlur} + className="pr-8" + /> + + % + + + {options.map(o => ( + +
+
+ ) +} + +interface NumberCellProps { + control: Control + name: FieldPath + step?: number + max?: number +} + +/** Numeric table-cell input bound to a form field. */ +export function NumberCell({ control, name, step = 1, max }: NumberCellProps) { + return ( + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + aria-invalid={invalid} + /> + )} + /> + ) +} + +/** Totals block under the items table. */ +export function GrnTotals({ lines }: { lines: CostedLine[] }) { + const t = grnTotals(lines) + const rows: [string, string][] = [ + ['Subtotal', money(t.subTotal)], + ['Discount', `- ${money(t.discount)}`], + ['Taxable amount', money(t.taxable)], + ['VAT', money(t.vat)], + ] + return ( +
+ + + {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ Grand total + {money(t.total)} +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx index 2d31ab6..0922e12 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx @@ -145,7 +145,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri {isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'} - {name} ({info.sku}) — accepting {accepted} + {name} ({info.sku}) — receiving {accepted} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-view-sheet.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-view-sheet.tsx index 894eeaf..b32de5f 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-view-sheet.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-view-sheet.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dialog' import { Separator } from '@/components/ui/separator' import type { GrnResponse } from '@/types/api' +import { money } from './grn-costing' const fmtDateTime = (v: string) => new Date(v).toLocaleString('en-GB', { @@ -37,7 +38,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) { return ( !v && onClose()}> - + Goods Receipt Details @@ -63,6 +64,11 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) { Item Accepted Rejected + Free + Unit Cost + Disc % + VAT % + Line Total @@ -94,11 +100,24 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) { {item.acceptedQuantity} {item.rejectedQuantity} + {item.freeQuantity} + {money(item.unitCost)} + {item.discountPercent}% + {item.vatPercent}% + {money(item.lineTotal)} ))} + +
+ + + + + {money(grn.totalAmount)}} /> +
diff --git a/distribution_fe/app/(dashboard)/procurement/grn/direct/new/page.tsx b/distribution_fe/app/(dashboard)/procurement/grn/direct/new/page.tsx deleted file mode 100644 index 5cfa515..0000000 --- a/distribution_fe/app/(dashboard)/procurement/grn/direct/new/page.tsx +++ /dev/null @@ -1,316 +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 { - grn as grnApi, - warehouses as warehousesApi, - users as usersApi, - suppliers as suppliersApi, - items as itemsApi, - getApiError, - getPaginatedItems, -} from '@/lib/api/index' -import type { WarehouseResponse, UserResponse, SupplierResponse, ItemSummaryResponse } from '@/types/api' -import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking' - -const NO_SUPPLIER = 'none' - -const lineSchema = z.object({ - 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'), - serialText: z.string(), - 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(), - receivedByUserId: z.string().min(1, 'Received by is required'), - items: z.array(lineSchema).min(1, 'Add at least one item'), -}) - -type FormValues = z.infer - -const emptyLine = { itemId: '', acceptedQuantity: 1, rejectedQuantity: 0, serialText: '', batches: [] } - -/** Receive goods into a warehouse with no purchase order. */ -export default function NewDirectGrnPage() { - 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 form = useForm({ - resolver: zodResolver(schema), - defaultValues: { warehouseId: '', supplierId: NO_SUPPLIER, receivedByUserId: '', items: [{ ...emptyLine }] }, - }) - - const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' }) - - useEffect(() => { - Promise.all([ - warehousesApi.list({ pageSize: 200 }), - usersApi.list({ pageSize: 200 }), - suppliersApi.list(), - itemsApi.list({ pageSize: 200 }), - ]) - .then(([ws, us, ss, its]) => { - setWarehousesList(getPaginatedItems(ws)) - setUsersList(getPaginatedItems(us)) - setSuppliersList(ss) - setItemsList(getPaginatedItems(its)) - }) - .catch(err => - toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) - ) - }, []) - - const itemById = (id: string) => itemsList.find(i => i.id === id) - - const watchedItems = form.watch('items') - const trackingIncomplete = watchedItems.some(v => { - const p = trackingProgress(itemById(v.itemId), v.acceptedQuantity, v.serialText, v.batches) - return p !== null && !p.complete - }) - - async function onSubmit(values: FormValues) { - const items: GrnItemBody[] = [] - for (const v of values.items) { - const info = itemById(v.itemId) - if (v.acceptedQuantity + v.rejectedQuantity <= 0) { - toast.add({ title: `${info?.sku ?? 'Line'}: enter a quantity.`, type: 'error' }) - return - } - const payload = trackingPayload(info, v) - if ('error' in payload) { - toast.add({ title: `${info?.sku ?? 'Line'}: ${payload.error}`, type: 'error' }) - return - } - items.push({ - itemId: v.itemId, - acceptedQuantity: v.acceptedQuantity, - rejectedQuantity: v.rejectedQuantity, - ...payload, - }) - } - - try { - await grnApi.createDirect({ - warehouseId: values.warehouseId, - supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId, - receivedByUserId: values.receivedByUserId, - items, - }) - toast.add({ title: 'Direct receipt recorded', type: 'success' }) - router.push('/procurement/grn') - } catch (err) { - toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' }) - } - } - - return ( -
-
- -
-

Direct Receipt

-

Receive stock into a warehouse without a purchase order.

-
-
- -
- -
- ( - - )} - /> - - ( - - )} - /> -
- - ( - - )} - /> -
- - - -
-

Items

- - {fields.map((f, index) => { - const v = watchedItems[index] - const info = v ? itemById(v.itemId) : undefined - return ( -
- ( - - )} - /> - - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - - - - {info && v && ( - form.setValue(`items.${index}.serialText`, val)} - onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })} - /> - )} -
- ) - })} - -
- -
-
- -
- - -
- -
- ) -} 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. +

+
+
+ +
+ + + Receipt Type +
+ + +
+
+ + {isPoMode && ( + + + Purchase Order * + + + + )} + +
+ ( + + )} + /> + + {isPoMode ? ( + + Supplier + + + ) : ( + ( + + )} + /> + )} + + {isPoMode && ( + ( + + )} + /> + )} + + applyToAllLines('discountPercent', v)} + /> + + applyToAllLines('vatPercent', v)} + /> +
+ + ( + + )} + /> +
+ + + +
+
+
+

Items

+

+ {isPoMode + ? 'Lines start from the purchase order. Add Item adds another line from the same order (e.g. a second batch). Unit cost starts at the PO price; change it if the supplier invoiced differently.' + : 'Add each received item as its own line.'}{' '} + GRN discount % and VAT % fill every line; edit a line to override it. Only accepted units are costed; + free units are stocked at no cost. +

+
+ +
+ + {loadingPO ? ( +

Loading items…

+ ) : isPoMode && !selectedPO ? ( +

Select a purchase order to load its items.

+ ) : fields.length === 0 ? ( +

+ {isPoMode ? 'No receivable items left. Use Add Item to add a line.' : 'Add an item to begin.'} +

+ ) : ( + <> +
+ + + + + + + + + + + + + + + + + {fields.map((f, index) => { + const v = watchedItems[index] + const info = v ? infoFor(v.itemId) : undefined + const po = v?.purchaseOrderItemId ? poLine(v.purchaseOrderItemId) : undefined + const amounts = lineAmounts({ + acceptedQuantity: v?.acceptedQuantity ?? 0, + unitCost: v?.unitCost ?? 0, + discountPercent: v?.discountPercent ?? 0, + vatPercent: v?.vatPercent ?? 0, + }) + return ( + + + + + + + + + + + + + + + + + + + {info && v && ( + + + + )} + + ) + })} + +
ItemBatch NoAcceptedRejectedFree QtyUnit CostDisc %VAT %Line Total
+ {isPoMode ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + + {batchSummary(info, v?.batches ?? [], v?.serialText ?? '')} + + + + + + + + + + + + + {money(amounts.total)} + +
+ form.setValue(`items.${index}.serialText`, val)} + onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })} + /> +
+
+ + + + )} +
+ +
+ + +
+ +
+ ) +} 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.

-
-
- -
- - ( - - )} - /> - -
- ( - - )} - /> - - ( - - )} - /> -
- - ( - - )} - /> -
- - {/* Line items — populated from the selected PO */} - {selectedPO && ( - <> - -
-

Line Items

- - {loadingPO ? ( -

Loading items…

- ) : poItems.length === 0 ? ( -

No receivable items on this order.

- ) : ( - <> - {/* Header row */} -
-

Item

-

Accepted

-

Rejected

-
- - {fields.map((f, index) => { - const poItem = poItems[index] - return ( -
-
- {poItem?.itemName} - - {poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity} - -
- - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - - ( - field.onChange(Number(e.target.value))} - onBlur={field.onBlur} - aria-invalid={invalid} - /> - )} - /> - - {poItem && itemInfo[poItem.itemId] && ( - form.setValue(`items.${index}.serialText`, v)} - onBatches={v => form.setValue(`items.${index}.batches`, v, { shouldDirty: true })} - /> - )} -
- ) - })} - - )} -
- - )} - -
- - -
- -
- ) -} 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 {