Fix GRN UI
This commit is contained in:
@@ -124,16 +124,10 @@ export function GrnClient() {
|
||||
Stock received into a warehouse, against a purchase order or directly
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<Button onClick={() => router.push('/procurement/grn/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||
New GRN
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import type { Control, FieldPath, FieldValues } from 'react-hook-form'
|
||||
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { ItemSummaryResponse } from '@/types/api'
|
||||
import type { BatchRow } from './grn-tracking'
|
||||
|
||||
/** Preset GRN-wide percentages. Any line can still be typed to a different value. */
|
||||
export const DISCOUNT_OPTIONS = [0, 2.5, 5, 10, 15, 20, 25]
|
||||
export const VAT_OPTIONS = [0, 5, 8, 12, 15, 18]
|
||||
|
||||
// Same rule as the server (GrnPricing): 2 dp, halves round away from zero, rounded per line.
|
||||
const round2 = (n: number) => 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 (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
list={listId}
|
||||
placeholder="Select or type"
|
||||
value={text}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
className="pr-8"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
<datalist id={listId}>
|
||||
{options.map(o => (
|
||||
<option key={o} value={o} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
interface NumberCellProps<T extends FieldValues> {
|
||||
control: Control<T>
|
||||
name: FieldPath<T>
|
||||
step?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
/** Numeric table-cell input bound to a form field. */
|
||||
export function NumberCell<T extends FieldValues>({ control, name, step = 1, max }: NumberCellProps<T>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => 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 (
|
||||
<div className="flex justify-end">
|
||||
<Card className="w-full max-w-sm rounded-2xl border-border/60">
|
||||
<CardContent className="space-y-1.5 text-sm">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between border-t pt-2 text-base font-semibold">
|
||||
<span>Grand total</span>
|
||||
<span>{money(t.total)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function LineTracking({ info, name, accepted, serialText, batches, onSeri
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{name} ({info.sku}) — accepting {accepted}
|
||||
{name} ({info.sku}) — receiving {accepted}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent className="overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Goods Receipt Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -63,6 +64,11 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
||||
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Accepted</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Rejected</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Free</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Unit Cost</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Disc %</th>
|
||||
<th className="px-3 py-2 text-right font-medium">VAT %</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Line Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -94,11 +100,24 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
|
||||
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
|
||||
<td className="px-3 py-2 text-right">{item.freeQuantity}</td>
|
||||
<td className="px-3 py-2 text-right">{money(item.unitCost)}</td>
|
||||
<td className="px-3 py-2 text-right">{item.discountPercent}%</td>
|
||||
<td className="px-3 py-2 text-right">{item.vatPercent}%</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{money(item.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 ml-auto flex max-w-xs flex-col">
|
||||
<Row label="Subtotal" value={money(grn.subTotal)} />
|
||||
<Row label={`Discount (GRN ${grn.discountPercent}%)`} value={`- ${money(grn.discountAmount)}`} />
|
||||
<Row label="Taxable amount" value={money(grn.taxableAmount)} />
|
||||
<Row label={`VAT (GRN ${grn.vatPercent}%)`} value={money(grn.vatAmount)} />
|
||||
<Row label="Grand total" value={<span className="text-base font-semibold">{money(grn.totalAmount)}</span>} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<WarehouseResponse[]>([])
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
|
||||
const [mode, setMode] = useState<GrnMode>('po')
|
||||
const [poId, setPoId] = useState('')
|
||||
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(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<Record<string, ItemSummaryResponse>>({})
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<div className="flex w-full 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">New GRN</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive stock into a warehouse. Pick a purchase order to receive against it, or leave it as direct.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>Receipt Type</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isPoMode ? 'default' : 'outline'}
|
||||
onClick={() => changeMode('po')}
|
||||
>
|
||||
Against PO
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={!isPoMode ? 'default' : 'outline'}
|
||||
onClick={() => changeMode('direct')}
|
||||
>
|
||||
Direct GRN
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{isPoMode && (
|
||||
<Field>
|
||||
<FieldLabel>
|
||||
Purchase Order<span className="text-destructive"> *</span>
|
||||
</FieldLabel>
|
||||
<Select value={poId} onValueChange={v => v && handlePOChange(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select purchase order..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligiblePOs.map(po => (
|
||||
<SelectItem key={po.id} value={po.id}>
|
||||
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPoMode ? (
|
||||
<Field>
|
||||
<FieldLabel>Supplier</FieldLabel>
|
||||
<Input value={selectedPO?.supplierName ?? ''} disabled readOnly />
|
||||
</Field>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPoMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="receivedAt"
|
||||
label="Received Date"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PercentSelect
|
||||
label="GRN Discount %"
|
||||
value={headerDiscount}
|
||||
options={DISCOUNT_OPTIONS}
|
||||
onChange={v => applyToAllLines('discountPercent', v)}
|
||||
/>
|
||||
|
||||
<PercentSelect
|
||||
label="GRN VAT %"
|
||||
value={headerVat}
|
||||
options={VAT_OPTIONS}
|
||||
onChange={v => applyToAllLines('vatPercent', v)}
|
||||
/>
|
||||
</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">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Items</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPoMode && !selectedPO}
|
||||
onClick={() => append({ ...emptyLine, discountPercent: headerDiscount, vatPercent: headerVat })}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingPO ? (
|
||||
<p className="text-sm text-muted-foreground">Loading items…</p>
|
||||
) : isPoMode && !selectedPO ? (
|
||||
<p className="text-sm text-muted-foreground">Select a purchase order to load its items.</p>
|
||||
) : fields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPoMode ? 'No receivable items left. Use Add Item to add a line.' : 'Add an item to begin.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="min-w-[120px] px-3 py-2 text-left font-medium">Batch No</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">Accepted</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">Rejected</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">Free Qty</th>
|
||||
<th className="w-28 px-3 py-2 text-left font-medium">Unit Cost</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">Disc %</th>
|
||||
<th className="w-24 px-3 py-2 text-left font-medium">VAT %</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
|
||||
<th className="w-16 px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<Fragment key={f.id}>
|
||||
<tr className="border-t align-top">
|
||||
<td className="px-3 py-2">
|
||||
{isPoMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.itemId`}
|
||||
render={() => (
|
||||
<Select
|
||||
value={v?.purchaseOrderItemId ?? ''}
|
||||
onValueChange={val => val && selectPoLine(index, val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select PO item...">
|
||||
{po ? `${po.sku} — ${po.itemName}` : undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{poLines.map(l => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.sku} — {l.itemName} (ordered {l.quantity}, received {l.receivedQuantity})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-3 text-muted-foreground">
|
||||
{batchSummary(info, v?.batches ?? [], v?.serialText ?? '')}
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.acceptedQuantity`} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.rejectedQuantity`} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.freeQuantity`} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.unitCost`} step={0.01} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.discountPercent`} step={0.01} max={100} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumberCell control={form.control} name={`items.${index}.vatPercent`} step={0.01} max={100} />
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-3 text-right font-medium">{money(amounts.total)}</td>
|
||||
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Delete item"
|
||||
title="Delete item"
|
||||
disabled={fields.length === 1}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{info && v && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-3 pb-3">
|
||||
<LineTracking
|
||||
info={info}
|
||||
name={info.name}
|
||||
accepted={v.acceptedQuantity + v.freeQuantity}
|
||||
serialText={v.serialText}
|
||||
batches={v.batches}
|
||||
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
|
||||
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<GrnTotals lines={watchedItems} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting || loadingPO || fields.length === 0 || trackingIncomplete}
|
||||
>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<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),
|
||||
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 (
|
||||
<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 flex-col gap-6 rounded-2xl border p-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="purchaseOrderId"
|
||||
label="Purchase Order"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={v => handlePOChange(v ?? '')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select purchase order..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligiblePOs.map(po => (
|
||||
<SelectItem key={po.id} value={po.id}>
|
||||
{po.supplierName} — {po.id.slice(0, 8).toUpperCase()} ({po.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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="receivedAt"
|
||||
label="Received Date"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<SelectItem value="" disabled>Select user...</SelectItem>
|
||||
{usersList.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{/* Line items — populated from the selected PO */}
|
||||
{selectedPO && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-foreground">Line Items</p>
|
||||
|
||||
{loadingPO ? (
|
||||
<p className="text-sm text-muted-foreground">Loading items…</p>
|
||||
) : poItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No receivable items on this order.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-[1fr_100px_100px] gap-3 px-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Item</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Accepted</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Rejected</p>
|
||||
</div>
|
||||
|
||||
{fields.map((f, index) => {
|
||||
const poItem = poItems[index]
|
||||
return (
|
||||
<div key={f.id} className="grid grid-cols-[1fr_100px_100px] items-start gap-3 rounded-xl border px-3 py-3">
|
||||
<div className="flex flex-col gap-0.5 pt-1">
|
||||
<span className="text-sm font-medium">{poItem?.itemName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{poItem?.sku} · ordered {poItem?.quantity}, received {poItem?.receivedQuantity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.acceptedQuantity`}
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
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}
|
||||
value={Number(field.value as number)}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user