Add item and GRN tracking forms

This commit is contained in:
2026-10-06 13:28:37 +05:30
parent a8725bc473
commit 92f1aadb81
16 changed files with 1536 additions and 764 deletions
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api'
import { GrnSheet } from './grn-sheet'
import { GrnViewSheet } from './grn-view-sheet'
const fmtDate = (v: string) =>
@@ -18,12 +18,12 @@ const fmtDate = (v: string) =>
const truncId = (id: string) => id.substring(0, 8)
export function GrnClient() {
const router = useRouter()
const [data, setData] = useState<GrnResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [viewing, setViewing] = useState<GrnResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
@@ -71,7 +71,9 @@ export function GrnClient() {
fields: ['purchaseOrderId'],
width: 140,
cellRender: (row) => (
<span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
row.purchaseOrderId
? <span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
: <span className="text-sm text-muted-foreground">Direct receipt</span>
),
},
{
@@ -119,13 +121,19 @@ export function GrnClient() {
<div>
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stock received into a warehouse against a purchase order
Stock received into a warehouse, against a purchase order or directly
</p>
</div>
<Button onClick={() => setSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Record Receipt
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Direct Receipt
</Button>
<Button onClick={() => router.push('/procurement/grn/po/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Receipt Against PO
</Button>
</div>
</div>
<DataTable
@@ -139,12 +147,6 @@ export function GrnClient() {
}}
/>
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<GrnViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
@@ -0,0 +1,324 @@
'use client'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import type { GrnBatchLineBody } from '@/lib/api/procurement'
import type { ItemSummaryResponse } from '@/types/api'
export function parseSerials(text: string): string[] {
return text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
}
export type BatchRow = { batchNumber: string; expiryDate: string; quantity: number }
interface Progress {
kind: 'serial' | 'batch'
done: number
complete: boolean
duplicates: string[]
}
/** How far a tracked line is from satisfying the server rules; null for untracked items. */
export function trackingProgress(
info: ItemSummaryResponse | undefined,
accepted: number,
serialText: string,
batches: BatchRow[],
): Progress | null {
if (info?.tracksSerialNumbers) {
const serials = parseSerials(serialText)
const seen = new Set<string>()
const dupes = new Set<string>()
for (const s of serials) {
if (seen.has(s)) dupes.add(s)
seen.add(s)
}
return {
kind: 'serial',
done: serials.length,
duplicates: [...dupes],
complete: accepted === 0 || (serials.length === accepted && dupes.size === 0),
}
}
if (info?.tracksBatches || info?.tracksExpiryDate) {
const done = batches.reduce((s, b) => s + (b.quantity || 0), 0)
const rowsOk = batches.every(
b =>
b.quantity > 0 &&
(!info.tracksBatches || b.batchNumber.trim() !== '') &&
(!info.tracksExpiryDate || b.expiryDate !== ''),
)
return { kind: 'batch', done, duplicates: [], complete: accepted === 0 || (done === accepted && rowsOk) }
}
return null
}
interface LineTrackingProps {
info: ItemSummaryResponse
name: string
accepted: number
serialText: string
batches: BatchRow[]
onSerialText: (v: string) => void
onBatches: (v: BatchRow[]) => void
}
/**
* Compact status chip for one GRN line; clicking it opens a panel for the actual serial/batch
* entry, so long serial lists don't stretch the form. Serial tracking takes precedence over
* batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale.
*/
export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) {
const [panelOpen, setPanelOpen] = useState(false)
const [scanValue, setScanValue] = useState('')
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
const [genStart, setGenStart] = useState(1)
const progress = trackingProgress(info, accepted, serialText, batches)
if (!progress) return null
// Scanner (keyboard-wedge) input: each scan ends with Enter and appends one serial.
const addScanned = () => {
const value = scanValue.trim()
if (!value) return
if (parseSerials(serialText).includes(value)) {
toast.add({ title: `Serial ${value} is already in the list`, type: 'error' })
} else {
onSerialText(serialText.trim() ? `${serialText.trimEnd()}\n${value}` : value)
}
setScanValue('')
}
// Appends generated serials (prefix + zero-padded sequence) for the units still missing,
// skipping any value already in the list. They stay editable in the list below.
const generateSerials = () => {
const existing = parseSerials(serialText)
const taken = new Set(existing)
const missing = Math.max(0, accepted - existing.length)
const generated: string[] = []
for (let n = genStart; generated.length < missing; n++) {
const sn = `${genPrefix}${String(n).padStart(4, '0')}`
if (!taken.has(sn)) generated.push(sn)
}
onSerialText([...existing, ...generated].join('\n'))
setGenStart(genStart + missing)
}
const isSerial = progress.kind === 'serial'
const noun = isSerial ? 'serials' : info.tracksBatches ? 'batches' : 'expiry lots'
const warrantyNote = info.hasWarranty
? `Warranty: ${info.warrantyPeriodDays != null && info.allowCustomWarrantyPeriod ? `${info.warrantyPeriodDays} days` : 'system default period'}, starts at sale.`
: null
const now = new Date()
const today = now.toISOString().slice(0, 10)
const inThirtyDays = new Date(now)
inThirtyDays.setDate(inThirtyDays.getDate() + 30)
const soon = inThirtyDays.toISOString().slice(0, 10)
const update = (i: number, patch: Partial<BatchRow>) =>
onBatches(batches.map((b, j) => (j === i ? { ...b, ...patch } : b)))
return (
<div className="col-span-full flex flex-wrap items-center gap-2 border-t pt-3">
<Button
type="button"
size="sm"
variant="outline"
className={progress.complete ? 'border-green-600/40 text-green-700' : 'border-amber-500/50 text-amber-700'}
onClick={() => setPanelOpen(true)}
>
{progress.complete ? '✓' : 'Enter'} {noun} · {progress.done}/{accepted}
</Button>
{warrantyNote && <span className="text-xs text-muted-foreground">{warrantyNote}</span>}
<Dialog open={panelOpen} onOpenChange={setPanelOpen}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
<DialogDescription>
{name} ({info.sku}) — accepting {accepted}
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{isSerial ? (
<>
<Input
autoFocus
placeholder="Scan a serial number and press Enter"
value={scanValue}
onChange={e => setScanValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
addScanned()
}
}}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Prefix"
value={genPrefix}
onChange={e => setGenPrefix(e.target.value)}
/>
<Input
type="number"
min={1}
className="w-24"
value={genStart}
onChange={e => setGenStart(Math.max(1, Number(e.target.value)))}
/>
<Button
type="button"
variant="outline"
disabled={accepted - progress.done <= 0}
onClick={generateSerials}
>
Auto-generate {Math.max(0, accepted - progress.done)}
</Button>
</div>
<textarea
className="min-h-40 w-full rounded-lg border bg-transparent px-3 py-2 font-mono text-xs"
placeholder="Serials appear here, one per line — type, paste or edit any of them"
value={serialText}
onChange={e => onSerialText(e.target.value)}
/>
<div className="flex items-center justify-between text-xs">
<span className={progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}>
{progress.done} of {accepted} entered
</span>
<button type="button" className="text-muted-foreground underline" onClick={() => onSerialText('')}>
Clear
</button>
</div>
{progress.duplicates.length > 0 && (
<p className="text-xs text-destructive">Duplicate: {progress.duplicates.join(', ')}</p>
)}
</>
) : (
<>
{batches.map((b, i) => (
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{info.tracksBatches && (
<Input
placeholder="Batch no."
value={b.batchNumber}
onChange={e => update(i, { batchNumber: e.target.value })}
/>
)}
{info.tracksBatches && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => update(i, { batchNumber: `B${today.replaceAll('-', '')}-${i + 1}` })}
>
Auto
</Button>
)}
{info.tracksExpiryDate && (
<Input type="date" value={b.expiryDate} onChange={e => update(i, { expiryDate: e.target.value })} />
)}
<Input
type="number"
min={1}
className="w-24"
placeholder="Qty"
value={b.quantity}
onChange={e => update(i, { quantity: Number(e.target.value) })}
/>
<Button type="button" variant="ghost" onClick={() => onBatches(batches.filter((_, j) => j !== i))}>
Remove
</Button>
</div>
{info.tracksExpiryDate && b.expiryDate && b.expiryDate < today && (
<span className="text-xs text-destructive">Already expired.</span>
)}
{info.tracksExpiryDate && b.expiryDate && b.expiryDate >= today && b.expiryDate <= soon && (
<span className="text-xs text-amber-700">Expires within 30 days.</span>
)}
</div>
))}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={() =>
onBatches([
...batches,
{ batchNumber: '', expiryDate: '', quantity: Math.max(0, accepted - progress.done) },
])
}
>
Add {info.tracksBatches ? 'batch' : 'expiry lot'}
</Button>
<span className={`text-xs ${progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}`}>
{progress.done} of {accepted} allocated
</span>
</div>
</>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => setPanelOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
export interface TrackedLineValues {
acceptedQuantity: number
serialText: string
batches: BatchRow[]
}
/**
* Mirrors the server-side rules in GrnService.ValidateLineShape so users get an inline message
* instead of a round trip. Serial tracking takes precedence over batch/expiry, as on the server.
* Returns the serial/batch payload for the line, or an error message.
*/
export function trackingPayload(
info: ItemSummaryResponse | undefined,
v: TrackedLineValues,
): { serialNumbers?: string[]; batches?: GrnBatchLineBody[] } | { error: string } {
if (info?.tracksSerialNumbers) {
const serials = parseSerials(v.serialText)
if (v.acceptedQuantity === 0) return {}
if (serials.length !== v.acceptedQuantity)
return { error: `enter exactly ${v.acceptedQuantity} serial number(s) (have ${serials.length}).` }
if (new Set(serials).size !== serials.length) return { error: 'duplicate serial numbers.' }
return { serialNumbers: serials }
}
if (info?.tracksBatches || info?.tracksExpiryDate) {
if (v.acceptedQuantity === 0) return {}
if (v.batches.length === 0) return { error: 'add at least one batch/expiry row.' }
if (v.batches.reduce((sum, b) => sum + b.quantity, 0) !== v.acceptedQuantity)
return { error: 'batch quantities must add up to the accepted quantity.' }
for (const b of v.batches) {
if (b.quantity <= 0) return { error: 'batch quantity must be greater than 0.' }
if (info.tracksBatches && !b.batchNumber.trim()) return { error: 'batch number is required.' }
if (info.tracksExpiryDate && !b.expiryDate) return { error: 'expiry date is required.' }
}
return {
batches: v.batches.map(b => ({
batchNumber: info.tracksBatches ? b.batchNumber.trim() : null,
expiryDate: info.tracksExpiryDate && b.expiryDate ? `${b.expiryDate}T00:00:00.000Z` : null,
quantity: b.quantity,
})),
}
}
return {}
}
@@ -14,6 +14,9 @@ const fmtDateTime = (v: string) =>
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface GrnViewSheetProps {
open: boolean
onClose: () => void
@@ -40,7 +43,10 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
</DialogHeader>
<div className="flex flex-col">
<Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
{grn.purchaseOrderId
? <Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
: <Row label="Type" value="Direct receipt (no purchase order)" />}
{grn.supplierName && <Row label="Supplier" value={grn.supplierName} />}
<Row label="Warehouse" value={grn.warehouseName} />
<Row label="Received At" value={fmtDateTime(grn.receivedAt)} />
<Row label="Created" value={fmtDateTime(grn.createdAt)} />
@@ -65,6 +71,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<td className="px-3 py-2">
{item.itemName}
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
{item.batches.length > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{item.batches.map((b, i) => (
<li key={i}>
{b.batchNumber ? `Batch ${b.batchNumber}` : 'Lot'} · qty {b.quantity}
{b.expiryDate && ` · expires ${fmtDate(b.expiryDate)}`}
</li>
))}
</ul>
)}
{item.serialNumbers.length > 0 && (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">
{item.serialNumbers.length} serial number{item.serialNumbers.length === 1 ? '' : 's'}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto font-mono">
{item.serialNumbers.map(sn => <li key={sn}>{sn}</li>)}
</ul>
</details>
)}
</td>
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
@@ -0,0 +1,316 @@
'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<typeof schema>
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<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
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 (
<div className="flex max-w-4xl flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Direct Receipt</h1>
<p className="text-sm text-muted-foreground">Receive stock into a warehouse without a purchase order.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="supplierId"
label="Supplier (optional)"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="No supplier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SUPPLIER}>No supplier</SelectItem>
{suppliersList.filter(s => s.isActive).map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<FormField
control={form.control}
name="receivedByUserId"
label="Received By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{usersList.map(user => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Items</p>
{fields.map((f, index) => {
const v = watchedItems[index]
const info = v ? itemById(v.itemId) : undefined
return (
<div key={f.id} className="grid grid-cols-[1fr_90px_90px_auto] items-start gap-3 rounded-xl border px-3 py-3">
<FormField
control={form.control}
name={`items.${index}.itemId`}
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={val => {
field.onChange(val)
// A different item has different tracking rules — drop stale entries.
form.setValue(`items.${index}.serialText`, '')
form.setValue(`items.${index}.batches`, [])
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.filter(i => i.isActive).map(i => (
<SelectItem key={i.id} value={i.id}>{i.sku} — {i.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Accepted"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Rejected"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<Button
type="button"
variant="ghost"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
Remove
</Button>
{info && v && (
<LineTracking
info={info}
name={info.name}
accepted={v.acceptedQuantity}
serialText={v.serialText}
batches={v.batches}
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
/>
)}
</div>
)
})}
<div>
<Button type="button" variant="outline" onClick={() => append({ ...emptyLine })}>
Add item
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</div>
</form>
</div>
)
}
@@ -1,6 +1,9 @@
'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'
@@ -13,31 +16,41 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
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 } from '@/types/api'
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({
@@ -50,22 +63,20 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
export default function NewPoGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(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<Record<string, ItemSummaryResponse>>({})
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -80,11 +91,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
// Load reference data when sheet opens
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(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), receivedByUserId: '', items: [] })
setSelectedPO(null)
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
@@ -100,7 +118,7 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
}, [])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
@@ -113,15 +131,21 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
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 = (po.items ?? [])
.filter(item => !item.isCancelled)
.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
}))
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' })
@@ -130,36 +154,58 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
}
}
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: values.items,
items,
receivedByUserId: values.receivedByUserId,
})
toast.add({ title: 'Goods receipt recorded', type: 'success' })
onSuccess()
onClose()
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Record Goods Receipt</DialogTitle>
<DialogDescription>
Record stock received into a warehouse against a purchase order.
</DialogDescription>
</DialogHeader>
<div className="flex max-w-4xl flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Receipt Against PO</h1>
<p className="text-sm text-muted-foreground">Record stock received into a warehouse against an issued purchase order.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto">
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6 rounded-2xl border p-6">
<FieldGroup>
<FormField
control={form.control}
@@ -304,6 +350,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
/>
)}
/>
{poItem && itemInfo[poItem.itemId] && (
<LineTracking
info={itemInfo[poItem.itemId]}
name={poItem.itemName}
accepted={form.watch(`items.${index}.acceptedQuantity`)}
serialText={form.watch(`items.${index}.serialText`)}
batches={form.watch(`items.${index}.batches`)}
onSerialText={v => form.setValue(`items.${index}.serialText`, v)}
onBatches={v => form.setValue(`items.${index}.batches`, v, { shouldDirty: true })}
/>
)}
</div>
)
})}
@@ -313,14 +371,13 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0 || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</DialogFooter>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}