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
|
Stock received into a warehouse, against a purchase order or directly
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button onClick={() => router.push('/procurement/grn/new')}>
|
||||||
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
|
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
New GRN
|
||||||
Direct Receipt
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={() => router.push('/procurement/grn/po/new')}>
|
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
|
|
||||||
Receipt Against PO
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
|
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{name} ({info.sku}) — accepting {accepted}
|
{name} ({info.sku}) — receiving {accepted}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import type { GrnResponse } from '@/types/api'
|
import type { GrnResponse } from '@/types/api'
|
||||||
|
import { money } from './grn-costing'
|
||||||
|
|
||||||
const fmtDateTime = (v: string) =>
|
const fmtDateTime = (v: string) =>
|
||||||
new Date(v).toLocaleString('en-GB', {
|
new Date(v).toLocaleString('en-GB', {
|
||||||
@@ -37,7 +38,7 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>Goods Receipt Details</DialogTitle>
|
<DialogTitle>Goods Receipt Details</DialogTitle>
|
||||||
</DialogHeader>
|
</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-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">Accepted</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Rejected</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -94,11 +100,24 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</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.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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -77,6 +77,13 @@ export interface GrnItemBody {
|
|||||||
batches?: GrnBatchLineBody[]
|
batches?: GrnBatchLineBody[]
|
||||||
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
|
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
|
||||||
serialNumbers?: string[]
|
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 {
|
export interface GrnBody {
|
||||||
@@ -84,6 +91,9 @@ export interface GrnBody {
|
|||||||
warehouseId: string
|
warehouseId: string
|
||||||
receivedAt: string
|
receivedAt: string
|
||||||
items: GrnItemBody[]
|
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.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
}
|
}
|
||||||
@@ -92,6 +102,8 @@ export interface DirectGrnBody {
|
|||||||
warehouseId: string
|
warehouseId: string
|
||||||
supplierId?: string
|
supplierId?: string
|
||||||
items: GrnItemBody[]
|
items: GrnItemBody[]
|
||||||
|
discountPercent?: number
|
||||||
|
vatPercent?: number
|
||||||
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
receivedByUserId: string
|
receivedByUserId: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,6 +418,17 @@ export interface GrnItemResponse {
|
|||||||
rejectedQuantity: number
|
rejectedQuantity: number
|
||||||
batches: GrnBatchLineResponse[]
|
batches: GrnBatchLineResponse[]
|
||||||
serialNumbers: string[]
|
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 {
|
export interface GrnResponse {
|
||||||
@@ -432,6 +443,14 @@ export interface GrnResponse {
|
|||||||
receivedAt: string
|
receivedAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
items: GrnItemResponse[]
|
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 {
|
export interface PurchaseReturnItemResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user