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