Add item and GRN tracking forms

This commit is contained in:
2026-10-06 13:28:37 +05:30
parent a8725bc473
commit 92f1aadb81
16 changed files with 1536 additions and 764 deletions
@@ -0,0 +1,406 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
const schema = z.object({
sku: z.string().min(1, 'SKU is required'),
name: z.string().min(1, 'Name is required'),
categoryId: z.string().min(1, 'Category is required'),
brandId: z.string().optional(),
baseUomId: z.string().min(1, 'Base UOM is required'),
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
tracksSerialNumbers: z.boolean(),
tracksBatches: z.boolean(),
tracksExpiryDate: z.boolean(),
hasWarranty: z.boolean(),
warrantyPeriodDays: z.number().min(0).optional(),
allowCustomWarrantyPeriod: z.boolean(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface TrackingToggles {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
}
const NO_TRACKING_ENABLED: TrackingToggles = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
}
const EMPTY_VALUES: FormValues = {
sku: '',
name: '',
categoryId: '',
brandId: undefined,
baseUomId: '',
salePrice: 0,
tracksSerialNumbers: false,
tracksBatches: false,
tracksExpiryDate: false,
hasWarranty: false,
warrantyPeriodDays: undefined,
allowCustomWarrantyPeriod: false,
isActive: true,
}
interface ItemFormProps {
item: ItemSummaryResponse | null
onSuccess: () => void
onCancel: () => void
twoColumn?: boolean
}
export function ItemForm({ item, onSuccess, onCancel, twoColumn = false }: ItemFormProps) {
const isEdit = !!item
const [categories, setCategories] = useState<CategoryResponse[]>([])
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
const [uomsList, setUomsList] = useState<UomResponse[]>([])
const [tracking, setTracking] = useState<TrackingToggles>(NO_TRACKING_ENABLED)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: EMPTY_VALUES,
})
useEffect(() => {
Promise.all([
categoriesApi.list(),
brandsApi.list(),
uomsApi.list(),
sysConfig.getAll(),
]).then(([cats, brandsRes, uomsRes, configs]) => {
setCategories(cats)
setBrandsList(brandsRes)
setUomsList(uomsRes)
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setTracking({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
})
}).catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
form.reset(
item
? {
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? undefined,
baseUomId: item.baseUomId,
salePrice: item.salePrice,
tracksSerialNumbers: item.tracksSerialNumbers,
tracksBatches: item.tracksBatches,
tracksExpiryDate: item.tracksExpiryDate,
hasWarranty: item.hasWarranty,
warrantyPeriodDays: item.warrantyPeriodDays ?? undefined,
allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod,
isActive: item.isActive,
}
: EMPTY_VALUES
)
}, [item])
const hasWarranty = form.watch('hasWarranty')
async function onSubmit(values: FormValues) {
try {
const body = {
...values,
brandId: values.brandId || null,
warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null,
}
if (isEdit) {
await itemsApi.update(item!.id, body)
toast.add({ title: 'Product updated', type: 'success' })
} else {
await itemsApi.create(body)
toast.add({ title: 'Product created', type: 'success' })
}
onSuccess()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup className={twoColumn ? 'grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2' : undefined}>
<FormField
control={form.control}
name="sku"
label="SKU"
required
render={({ field, invalid }) => (
<Input
id="sku"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. ITEM-001"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="name"
label="Product Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Coca-Cola 330ml"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="categoryId"
label="Category"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="brandId"
label="Brand"
render={({ field }) => (
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder="Select brand..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{brandsList.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="baseUomId"
label="Base UOM"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select unit of measure..." />
</SelectTrigger>
<SelectContent>
{uomsList.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="salePrice"
label="Sale Price"
required
render={({ field, invalid }) => (
<Input
id="salePrice"
name={field.name}
ref={field.ref}
type="number"
min={0}
step="0.01"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
/>
)}
/>
{(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && (
<div className="flex flex-col gap-3 rounded-md border p-3 md:col-span-2">
<span className="text-sm font-medium text-foreground">Tracking</span>
{tracking.serialNumberTracking && (
<FormField
control={form.control}
name="tracksSerialNumbers"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksSerialNumbers" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksSerialNumbers" className="text-sm font-medium cursor-pointer">Track serial numbers</label>
</div>
)}
/>
)}
{tracking.batchTracking && (
<FormField
control={form.control}
name="tracksBatches"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksBatches" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksBatches" className="text-sm font-medium cursor-pointer">Track batches</label>
</div>
)}
/>
)}
{tracking.expiryDateTracking && (
<FormField
control={form.control}
name="tracksExpiryDate"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksExpiryDate" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksExpiryDate" className="text-sm font-medium cursor-pointer">Track expiry date</label>
</div>
)}
/>
)}
{tracking.warrantyTracking && (
<>
<FormField
control={form.control}
name="hasWarranty"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="hasWarranty" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="hasWarranty" className="text-sm font-medium cursor-pointer">Has warranty</label>
</div>
)}
/>
{hasWarranty && tracking.customWarrantyOverride && (
<>
<FormField
control={form.control}
name="allowCustomWarrantyPeriod"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="allowCustomWarrantyPeriod" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="allowCustomWarrantyPeriod" className="text-sm font-medium cursor-pointer">Use a custom warranty period</label>
</div>
)}
/>
{form.watch('allowCustomWarrantyPeriod') && (
<FormField
control={form.control}
name="warrantyPeriodDays"
label="Warranty period (days)"
render={({ field }) => (
<Input
id="warrantyPeriodDays"
type="number"
min={0}
value={field.value === undefined ? '' : String(field.value)}
onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)}
onBlur={field.onBlur}
placeholder="System default"
/>
)}
/>
)}
</>
)}
</>
)}
</div>
)}
<div className="md:col-span-2">
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</div>
</FieldGroup>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Product'}
</Button>
</div>
</form>
)
}
@@ -1,437 +1,40 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
import type { ItemSummaryResponse } from '@/types/api'
const schema = z.object({
sku: z.string().min(1, 'SKU is required'),
name: z.string().min(1, 'Name is required'),
categoryId: z.string().min(1, 'Category is required'),
brandId: z.string().optional(),
baseUomId: z.string().min(1, 'Base UOM is required'),
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
tracksSerialNumbers: z.boolean(),
tracksBatches: z.boolean(),
tracksExpiryDate: z.boolean(),
hasWarranty: z.boolean(),
warrantyPeriodDays: z.number().min(0).optional(),
allowCustomWarrantyPeriod: z.boolean(),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface TrackingToggles {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
}
const NO_TRACKING_ENABLED: TrackingToggles = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
}
import { ItemForm } from './item-form'
interface ItemSheetProps {
open: boolean
onClose: () => void
item: ItemSummaryResponse | null
item: ItemSummaryResponse
onSuccess: () => void
}
export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
const isEdit = !!item
const [categories, setCategories] = useState<CategoryResponse[]>([])
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
const [uomsList, setUomsList] = useState<UomResponse[]>([])
const [tracking, setTracking] = useState<TrackingToggles>(NO_TRACKING_ENABLED)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
sku: '',
name: '',
categoryId: '',
brandId: undefined,
baseUomId: '',
salePrice: 0,
tracksSerialNumbers: false,
tracksBatches: false,
tracksExpiryDate: false,
hasWarranty: false,
warrantyPeriodDays: undefined,
allowCustomWarrantyPeriod: false,
isActive: true,
},
})
useEffect(() => {
if (open) {
Promise.all([
categoriesApi.list(),
brandsApi.list(),
uomsApi.list(),
sysConfig.getAll(),
]).then(([cats, brandsRes, uomsRes, configs]) => {
setCategories(cats)
setBrandsList(brandsRes)
setUomsList(uomsRes)
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setTracking({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
})
}).catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
})
form.reset(
item
? {
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? undefined,
baseUomId: item.baseUomId,
salePrice: item.salePrice,
tracksSerialNumbers: item.tracksSerialNumbers,
tracksBatches: item.tracksBatches,
tracksExpiryDate: item.tracksExpiryDate,
hasWarranty: item.hasWarranty,
warrantyPeriodDays: item.warrantyPeriodDays ?? undefined,
allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod,
isActive: item.isActive,
}
: {
sku: '',
name: '',
categoryId: '',
brandId: undefined,
baseUomId: '',
salePrice: 0,
tracksSerialNumbers: false,
tracksBatches: false,
tracksExpiryDate: false,
hasWarranty: false,
warrantyPeriodDays: undefined,
allowCustomWarrantyPeriod: false,
isActive: true,
}
)
}
}, [open, item])
const hasWarranty = form.watch('hasWarranty')
async function onSubmit(values: FormValues) {
try {
const body = {
...values,
brandId: values.brandId || null,
warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null,
}
if (isEdit) {
await itemsApi.update(item!.id, body)
toast.add({ title: 'Product updated', type: 'success' })
} else {
await itemsApi.create(body)
toast.add({ title: 'Product created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Product' : 'Add Product'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'}
</DialogDescription>
<DialogTitle>Edit Product</DialogTitle>
<DialogDescription>Update the product details below.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="sku"
label="SKU"
required
render={({ field, invalid }) => (
<Input
id="sku"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. ITEM-001"
aria-invalid={invalid}
<ItemForm
item={item}
onSuccess={() => {
onSuccess()
onClose()
}}
onCancel={onClose}
/>
)}
/>
<FormField
control={form.control}
name="name"
label="Product Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Coca-Cola 330ml"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="categoryId"
label="Category"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="brandId"
label="Brand"
render={({ field }) => (
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder="Select brand..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{brandsList.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="baseUomId"
label="Base UOM"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select unit of measure..." />
</SelectTrigger>
<SelectContent>
{uomsList.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="salePrice"
label="Sale Price"
required
render={({ field, invalid }) => (
<Input
id="salePrice"
name={field.name}
ref={field.ref}
type="number"
min={0}
step="0.01"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
/>
)}
/>
{(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && (
<div className="flex flex-col gap-3 rounded-md border p-3">
<span className="text-sm font-medium text-foreground">Tracking</span>
{tracking.serialNumberTracking && (
<FormField
control={form.control}
name="tracksSerialNumbers"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksSerialNumbers" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksSerialNumbers" className="text-sm font-medium cursor-pointer">Track serial numbers</label>
</div>
)}
/>
)}
{tracking.batchTracking && (
<FormField
control={form.control}
name="tracksBatches"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksBatches" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksBatches" className="text-sm font-medium cursor-pointer">Track batches</label>
</div>
)}
/>
)}
{tracking.expiryDateTracking && (
<FormField
control={form.control}
name="tracksExpiryDate"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="tracksExpiryDate" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="tracksExpiryDate" className="text-sm font-medium cursor-pointer">Track expiry date</label>
</div>
)}
/>
)}
{tracking.warrantyTracking && (
<>
<FormField
control={form.control}
name="hasWarranty"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="hasWarranty" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="hasWarranty" className="text-sm font-medium cursor-pointer">Has warranty</label>
</div>
)}
/>
{hasWarranty && tracking.customWarrantyOverride && (
<>
<FormField
control={form.control}
name="allowCustomWarrantyPeriod"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="allowCustomWarrantyPeriod" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="allowCustomWarrantyPeriod" className="text-sm font-medium cursor-pointer">Use a custom warranty period</label>
</div>
)}
/>
{form.watch('allowCustomWarrantyPeriod') && (
<FormField
control={form.control}
name="warrantyPeriodDays"
label="Warranty period (days)"
render={({ field }) => (
<Input
id="warrantyPeriodDays"
type="number"
min={0}
value={field.value === undefined ? '' : String(field.value)}
onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)}
onBlur={field.onBlur}
placeholder="System default"
/>
)}
/>
)}
</>
)}
</>
)}
</div>
)}
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Product'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
@@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
<Row label="Brand" value={item.brandName ?? '—'} />
<Row label="Base UOM" value={item.baseUomName} />
<Row label="Sale Price" value={`Rs. ${item.salePrice?.toFixed(2) ?? '0.00'}`} />
<Row
label="Tracking"
value={
item.tracksSerialNumbers || item.tracksBatches || item.tracksExpiryDate || item.hasWarranty ? (
<span className="flex flex-wrap justify-end gap-1">
{item.tracksSerialNumbers && <Badge variant="outline">Serial</Badge>}
{item.tracksBatches && <Badge variant="outline">Batch</Badge>}
{item.tracksExpiryDate && <Badge variant="outline">Expiry</Badge>}
{item.hasWarranty && (
<Badge variant="outline">
Warranty {item.allowCustomWarrantyPeriod && item.warrantyPeriodDays != null
? `${item.warrantyPeriodDays}d`
: '(default period)'}
</Badge>
)}
</span>
) : (
'—'
)
}
/>
<Row label="Sync Source" value={item.syncSource} />
<Row label="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Created At" value={fmtDate(item.createdAt)} />
@@ -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'
@@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet'
import { ItemViewSheet } from './item-view-sheet'
export function ItemsClient() {
const router = useRouter()
const [data, setData] = useState<ItemSummaryResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -44,11 +46,6 @@ export function ItemsClient() {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(item: ItemSummaryResponse) {
setEditing(item)
setSheetOpen(true)
@@ -164,7 +161,7 @@ export function ItemsClient() {
Products available for sale and distribution
</p>
</div>
<Button onClick={openCreate}>
<Button onClick={() => router.push('/catalogue/items/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add Product
</Button>
@@ -181,12 +178,14 @@ export function ItemsClient() {
}}
/>
{editing && (
<ItemSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
item={editing}
onSuccess={() => fetchData(page, pageSize)}
/>
)}
<ItemViewSheet
open={viewOpen}
@@ -0,0 +1,32 @@
'use client'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
import { ItemForm } from '../_components/item-form'
export default function NewItemPage() {
const router = useRouter()
const goBack = () => router.push('/catalogue/items')
return (
<div className="flex flex-col gap-6 max-w-4xl">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to products">
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Add Product</h1>
<p className="text-sm text-muted-foreground mt-0.5">Add a new catalogue product.</p>
</div>
</div>
<div className="rounded-lg border bg-card p-6">
<ItemForm item={null} onSuccess={goBack} onCancel={goBack} twoColumn />
</div>
</div>
)
}
@@ -0,0 +1,220 @@
'use client'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Package01Icon } from '@hugeicons/core-free-icons'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { FieldLabel, FieldDescription } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { useAuth } from '@/contexts/auth-context'
interface FormState {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
defaultWarrantyPeriodDays: number
}
const DEFAULTS: FormState = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
defaultWarrantyPeriodDays: 0,
}
interface CapabilityToggleProps {
id: string
label: string
description: string
checked: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
className?: string
}
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
className,
)}
>
<div className="flex flex-col gap-0.5">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</FieldLabel>
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
</div>
</div>
)
}
/** The Products configuration card: tracking capability switches and warranty defaults, saved immediately. */
export function ProductCapabilitiesCard() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormState>(DEFAULTS)
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
useEffect(() => {
sysConfig.getAll()
.then((configs) => {
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setForm({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
})
})
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
const previous = form[formKey]
setForm(f => ({ ...f, [formKey]: value }))
setPendingKeys(p => ({ ...p, [configKey]: true }))
try {
await sysConfig.set(configKey, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, [formKey]: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [configKey]: false }))
}
}
async function updateDefaultWarrantyDays(value: number) {
const previous = form.defaultWarrantyPeriodDays
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
if (value === previous) return
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
try {
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
}
}
return loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Card className="max-w-4xl rounded-3xl border-border/60">
<CardHeader className="border-b border-border/60 pb-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Package01Icon} size={18} />
</div>
<CardTitle className="text-lg">Products</CardTitle>
</div>
<CardDescription className="mt-3">
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
items then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader>
<CardContent className="pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="serialNumberTracking"
label="Serial number tracking"
description="Track individual units of an item by a unique serial number."
checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
/>
<CapabilityToggle
id="batchTracking"
label="Batch / lot tracking"
description="Group stock into batches or lots for traceability."
checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
/>
<CapabilityToggle
id="expiryDateTracking"
label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock."
checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
/>
<CapabilityToggle
id="warrantyTracking"
label="Warranty periods"
description="Attach a warranty period to items at the time of sale."
checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
/>
</div>
{form.warrantyTracking && (
<>
<Separator className="my-5" />
<div className="flex flex-col gap-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="customWarrantyOverride"
label="Per-item custom warranty"
description="Allow individual items to override the default warranty period."
checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/>
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="defaultWarrantyPeriodDays"
type="number"
min={0}
value={String(form.defaultWarrantyPeriodDays)}
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
className="max-w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<FieldDescription className="text-xs">
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
)
}
@@ -1,27 +1,8 @@
'use client'
import Link from 'next/link'
import { HugeiconsIcon } from '@hugeicons/react'
import { Package01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from '@/components/ui/card'
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
interface ConfigCategory {
title: string
description: string
href: string
icon: typeof Package01Icon
}
const CATEGORIES: ConfigCategory[] = [
{
title: 'Products',
description: 'Serial number, batch, expiry and warranty tracking for catalogue items',
href: '/configuration/products',
icon: Package01Icon,
},
]
export default function configurationPage() {
export default function ConfigurationPage() {
return (
<div className="flex flex-col gap-6">
<div>
@@ -29,26 +10,7 @@ export default function configurationPage() {
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{CATEGORIES.map((category) => (
<Link key={category.href} href={category.href} className="block">
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={category.icon} size={18} />
</div>
<CardTitle>{category.title}</CardTitle>
</div>
<CardDescription className="mt-3">{category.description}</CardDescription>
<CardAction>
<HugeiconsIcon icon={ArrowRight01Icon} size={16} className="text-muted-foreground" />
</CardAction>
</CardHeader>
</Card>
</Link>
))}
</div>
<ProductCapabilitiesCard />
</div>
)
}
@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { FieldLabel, FieldDescription } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { useAuth } from '@/contexts/auth-context'
interface FormState {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
defaultWarrantyPeriodDays: number
}
const DEFAULTS: FormState = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
defaultWarrantyPeriodDays: 0,
}
interface CapabilityToggleProps {
id: string
label: string
description: string
checked: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
className?: string
}
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
className,
)}
>
<div className="flex flex-col gap-0.5">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</FieldLabel>
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
</div>
</div>
)
}
export default function ProductsConfigurationPage() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormState>(DEFAULTS)
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
useEffect(() => {
sysConfig.getAll()
.then((configs) => {
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setForm({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
})
})
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
const previous = form[formKey]
setForm(f => ({ ...f, [formKey]: value }))
setPendingKeys(p => ({ ...p, [configKey]: true }))
try {
await sysConfig.set(configKey, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, [formKey]: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [configKey]: false }))
}
}
async function updateDefaultWarrantyDays(value: number) {
const previous = form.defaultWarrantyPeriodDays
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
if (value === previous) return
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
try {
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
}
}
return (
<div className="flex flex-col gap-8 bg-muted/30">
<div className="flex flex-col gap-3">
<Link
href="/configuration"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={14} />
Configuration
</Link>
<div>
<h1 className="text-3xl font-semibold tracking-tight text-foreground">Product Configuration</h1>
<p className="mt-1 text-sm text-muted-foreground">
Control which tracking capabilities are available to products across the catalogue.
</p>
</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Card className="max-w-4xl rounded-3xl border-border/60">
<CardHeader className="border-b border-border/60 pb-5">
<CardTitle className="text-lg">Product Capabilities</CardTitle>
<CardDescription>
Each switch applies immediately. Items can then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader>
<CardContent className="pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="serialNumberTracking"
label="Serial number tracking"
description="Track individual units of an item by a unique serial number."
checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
/>
<CapabilityToggle
id="batchTracking"
label="Batch / lot tracking"
description="Group stock into batches or lots for traceability."
checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
/>
<CapabilityToggle
id="expiryDateTracking"
label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock."
checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
/>
<CapabilityToggle
id="warrantyTracking"
label="Warranty periods"
description="Attach a warranty period to items at the time of sale."
checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
/>
</div>
{form.warrantyTracking && (
<>
<Separator className="my-5" />
<div className="flex flex-col gap-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="customWarrantyOverride"
label="Per-item custom warranty"
description="Allow individual items to override the default warranty period."
checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/>
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="defaultWarrantyPeriodDays"
type="number"
min={0}
value={String(form.defaultWarrantyPeriodDays)}
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
className="max-w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<FieldDescription className="text-xs">
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)
}
@@ -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)}>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Record Receipt
Direct Receipt
</Button>
<Button onClick={() => router.push('/procurement/grn/po/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Receipt Against PO
</Button>
</div>
</div>
<DataTable
@@ -139,12 +147,6 @@ export function GrnClient() {
}}
/>
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<GrnViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
@@ -0,0 +1,324 @@
'use client'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import type { GrnBatchLineBody } from '@/lib/api/procurement'
import type { ItemSummaryResponse } from '@/types/api'
export function parseSerials(text: string): string[] {
return text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
}
export type BatchRow = { batchNumber: string; expiryDate: string; quantity: number }
interface Progress {
kind: 'serial' | 'batch'
done: number
complete: boolean
duplicates: string[]
}
/** How far a tracked line is from satisfying the server rules; null for untracked items. */
export function trackingProgress(
info: ItemSummaryResponse | undefined,
accepted: number,
serialText: string,
batches: BatchRow[],
): Progress | null {
if (info?.tracksSerialNumbers) {
const serials = parseSerials(serialText)
const seen = new Set<string>()
const dupes = new Set<string>()
for (const s of serials) {
if (seen.has(s)) dupes.add(s)
seen.add(s)
}
return {
kind: 'serial',
done: serials.length,
duplicates: [...dupes],
complete: accepted === 0 || (serials.length === accepted && dupes.size === 0),
}
}
if (info?.tracksBatches || info?.tracksExpiryDate) {
const done = batches.reduce((s, b) => s + (b.quantity || 0), 0)
const rowsOk = batches.every(
b =>
b.quantity > 0 &&
(!info.tracksBatches || b.batchNumber.trim() !== '') &&
(!info.tracksExpiryDate || b.expiryDate !== ''),
)
return { kind: 'batch', done, duplicates: [], complete: accepted === 0 || (done === accepted && rowsOk) }
}
return null
}
interface LineTrackingProps {
info: ItemSummaryResponse
name: string
accepted: number
serialText: string
batches: BatchRow[]
onSerialText: (v: string) => void
onBatches: (v: BatchRow[]) => void
}
/**
* Compact status chip for one GRN line; clicking it opens a panel for the actual serial/batch
* entry, so long serial lists don't stretch the form. Serial tracking takes precedence over
* batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale.
*/
export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) {
const [panelOpen, setPanelOpen] = useState(false)
const [scanValue, setScanValue] = useState('')
const [genPrefix, setGenPrefix] = useState(`${info.sku}-`)
const [genStart, setGenStart] = useState(1)
const progress = trackingProgress(info, accepted, serialText, batches)
if (!progress) return null
// Scanner (keyboard-wedge) input: each scan ends with Enter and appends one serial.
const addScanned = () => {
const value = scanValue.trim()
if (!value) return
if (parseSerials(serialText).includes(value)) {
toast.add({ title: `Serial ${value} is already in the list`, type: 'error' })
} else {
onSerialText(serialText.trim() ? `${serialText.trimEnd()}\n${value}` : value)
}
setScanValue('')
}
// Appends generated serials (prefix + zero-padded sequence) for the units still missing,
// skipping any value already in the list. They stay editable in the list below.
const generateSerials = () => {
const existing = parseSerials(serialText)
const taken = new Set(existing)
const missing = Math.max(0, accepted - existing.length)
const generated: string[] = []
for (let n = genStart; generated.length < missing; n++) {
const sn = `${genPrefix}${String(n).padStart(4, '0')}`
if (!taken.has(sn)) generated.push(sn)
}
onSerialText([...existing, ...generated].join('\n'))
setGenStart(genStart + missing)
}
const isSerial = progress.kind === 'serial'
const noun = isSerial ? 'serials' : info.tracksBatches ? 'batches' : 'expiry lots'
const warrantyNote = info.hasWarranty
? `Warranty: ${info.warrantyPeriodDays != null && info.allowCustomWarrantyPeriod ? `${info.warrantyPeriodDays} days` : 'system default period'}, starts at sale.`
: null
const now = new Date()
const today = now.toISOString().slice(0, 10)
const inThirtyDays = new Date(now)
inThirtyDays.setDate(inThirtyDays.getDate() + 30)
const soon = inThirtyDays.toISOString().slice(0, 10)
const update = (i: number, patch: Partial<BatchRow>) =>
onBatches(batches.map((b, j) => (j === i ? { ...b, ...patch } : b)))
return (
<div className="col-span-full flex flex-wrap items-center gap-2 border-t pt-3">
<Button
type="button"
size="sm"
variant="outline"
className={progress.complete ? 'border-green-600/40 text-green-700' : 'border-amber-500/50 text-amber-700'}
onClick={() => setPanelOpen(true)}
>
{progress.complete ? '✓' : 'Enter'} {noun} · {progress.done}/{accepted}
</Button>
{warrantyNote && <span className="text-xs text-muted-foreground">{warrantyNote}</span>}
<Dialog open={panelOpen} onOpenChange={setPanelOpen}>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'}</DialogTitle>
<DialogDescription>
{name} ({info.sku}) — accepting {accepted}
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{isSerial ? (
<>
<Input
autoFocus
placeholder="Scan a serial number and press Enter"
value={scanValue}
onChange={e => setScanValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
addScanned()
}
}}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Prefix"
value={genPrefix}
onChange={e => setGenPrefix(e.target.value)}
/>
<Input
type="number"
min={1}
className="w-24"
value={genStart}
onChange={e => setGenStart(Math.max(1, Number(e.target.value)))}
/>
<Button
type="button"
variant="outline"
disabled={accepted - progress.done <= 0}
onClick={generateSerials}
>
Auto-generate {Math.max(0, accepted - progress.done)}
</Button>
</div>
<textarea
className="min-h-40 w-full rounded-lg border bg-transparent px-3 py-2 font-mono text-xs"
placeholder="Serials appear here, one per line — type, paste or edit any of them"
value={serialText}
onChange={e => onSerialText(e.target.value)}
/>
<div className="flex items-center justify-between text-xs">
<span className={progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}>
{progress.done} of {accepted} entered
</span>
<button type="button" className="text-muted-foreground underline" onClick={() => onSerialText('')}>
Clear
</button>
</div>
{progress.duplicates.length > 0 && (
<p className="text-xs text-destructive">Duplicate: {progress.duplicates.join(', ')}</p>
)}
</>
) : (
<>
{batches.map((b, i) => (
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{info.tracksBatches && (
<Input
placeholder="Batch no."
value={b.batchNumber}
onChange={e => update(i, { batchNumber: e.target.value })}
/>
)}
{info.tracksBatches && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => update(i, { batchNumber: `B${today.replaceAll('-', '')}-${i + 1}` })}
>
Auto
</Button>
)}
{info.tracksExpiryDate && (
<Input type="date" value={b.expiryDate} onChange={e => update(i, { expiryDate: e.target.value })} />
)}
<Input
type="number"
min={1}
className="w-24"
placeholder="Qty"
value={b.quantity}
onChange={e => update(i, { quantity: Number(e.target.value) })}
/>
<Button type="button" variant="ghost" onClick={() => onBatches(batches.filter((_, j) => j !== i))}>
Remove
</Button>
</div>
{info.tracksExpiryDate && b.expiryDate && b.expiryDate < today && (
<span className="text-xs text-destructive">Already expired.</span>
)}
{info.tracksExpiryDate && b.expiryDate && b.expiryDate >= today && b.expiryDate <= soon && (
<span className="text-xs text-amber-700">Expires within 30 days.</span>
)}
</div>
))}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={() =>
onBatches([
...batches,
{ batchNumber: '', expiryDate: '', quantity: Math.max(0, accepted - progress.done) },
])
}
>
Add {info.tracksBatches ? 'batch' : 'expiry lot'}
</Button>
<span className={`text-xs ${progress.done === accepted ? 'text-muted-foreground' : 'text-destructive'}`}>
{progress.done} of {accepted} allocated
</span>
</div>
</>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => setPanelOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
export interface TrackedLineValues {
acceptedQuantity: number
serialText: string
batches: BatchRow[]
}
/**
* Mirrors the server-side rules in GrnService.ValidateLineShape so users get an inline message
* instead of a round trip. Serial tracking takes precedence over batch/expiry, as on the server.
* Returns the serial/batch payload for the line, or an error message.
*/
export function trackingPayload(
info: ItemSummaryResponse | undefined,
v: TrackedLineValues,
): { serialNumbers?: string[]; batches?: GrnBatchLineBody[] } | { error: string } {
if (info?.tracksSerialNumbers) {
const serials = parseSerials(v.serialText)
if (v.acceptedQuantity === 0) return {}
if (serials.length !== v.acceptedQuantity)
return { error: `enter exactly ${v.acceptedQuantity} serial number(s) (have ${serials.length}).` }
if (new Set(serials).size !== serials.length) return { error: 'duplicate serial numbers.' }
return { serialNumbers: serials }
}
if (info?.tracksBatches || info?.tracksExpiryDate) {
if (v.acceptedQuantity === 0) return {}
if (v.batches.length === 0) return { error: 'add at least one batch/expiry row.' }
if (v.batches.reduce((sum, b) => sum + b.quantity, 0) !== v.acceptedQuantity)
return { error: 'batch quantities must add up to the accepted quantity.' }
for (const b of v.batches) {
if (b.quantity <= 0) return { error: 'batch quantity must be greater than 0.' }
if (info.tracksBatches && !b.batchNumber.trim()) return { error: 'batch number is required.' }
if (info.tracksExpiryDate && !b.expiryDate) return { error: 'expiry date is required.' }
}
return {
batches: v.batches.map(b => ({
batchNumber: info.tracksBatches ? b.batchNumber.trim() : null,
expiryDate: info.tracksExpiryDate && b.expiryDate ? `${b.expiryDate}T00:00:00.000Z` : null,
quantity: b.quantity,
})),
}
}
return {}
}
@@ -14,6 +14,9 @@ const fmtDateTime = (v: string) =>
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface GrnViewSheetProps {
open: boolean
onClose: () => void
@@ -40,7 +43,10 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
</DialogHeader>
<div className="flex flex-col">
<Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
{grn.purchaseOrderId
? <Row label="Purchase Order" value={<span className="font-mono text-xs">{grn.purchaseOrderId}</span>} />
: <Row label="Type" value="Direct receipt (no purchase order)" />}
{grn.supplierName && <Row label="Supplier" value={grn.supplierName} />}
<Row label="Warehouse" value={grn.warehouseName} />
<Row label="Received At" value={fmtDateTime(grn.receivedAt)} />
<Row label="Created" value={fmtDateTime(grn.createdAt)} />
@@ -65,6 +71,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<td className="px-3 py-2">
{item.itemName}
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span>
{item.batches.length > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{item.batches.map((b, i) => (
<li key={i}>
{b.batchNumber ? `Batch ${b.batchNumber}` : 'Lot'} · qty {b.quantity}
{b.expiryDate && ` · expires ${fmtDate(b.expiryDate)}`}
</li>
))}
</ul>
)}
{item.serialNumbers.length > 0 && (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">
{item.serialNumbers.length} serial number{item.serialNumbers.length === 1 ? '' : 's'}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto font-mono">
{item.serialNumbers.map(sn => <li key={sn}>{sn}</li>)}
</ul>
</details>
)}
</td>
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
@@ -0,0 +1,316 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import {
grn as grnApi,
warehouses as warehousesApi,
users as usersApi,
suppliers as suppliersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { WarehouseResponse, UserResponse, SupplierResponse, ItemSummaryResponse } from '@/types/api'
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
const NO_SUPPLIER = 'none'
const lineSchema = z.object({
itemId: z.string().min(1, 'Select an item'),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
serialText: z.string(),
batches: z.array(z.object({ batchNumber: z.string(), expiryDate: z.string(), quantity: z.number() })),
})
const schema = z.object({
warehouseId: z.string().min(1, 'Warehouse is required'),
supplierId: z.string(),
receivedByUserId: z.string().min(1, 'Received by is required'),
items: z.array(lineSchema).min(1, 'Add at least one item'),
})
type FormValues = z.infer<typeof schema>
const emptyLine = { itemId: '', acceptedQuantity: 1, rejectedQuantity: 0, serialText: '', batches: [] }
/** Receive goods into a warehouse with no purchase order. */
export default function NewDirectGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [suppliersList, setSuppliersList] = useState<SupplierResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { warehouseId: '', supplierId: NO_SUPPLIER, receivedByUserId: '', items: [{ ...emptyLine }] },
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' })
useEffect(() => {
Promise.all([
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
suppliersApi.list(),
itemsApi.list({ pageSize: 200 }),
])
.then(([ws, us, ss, its]) => {
setWarehousesList(getPaginatedItems(ws))
setUsersList(getPaginatedItems(us))
setSuppliersList(ss)
setItemsList(getPaginatedItems(its))
})
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [])
const itemById = (id: string) => itemsList.find(i => i.id === id)
const watchedItems = form.watch('items')
const trackingIncomplete = watchedItems.some(v => {
const p = trackingProgress(itemById(v.itemId), v.acceptedQuantity, v.serialText, v.batches)
return p !== null && !p.complete
})
async function onSubmit(values: FormValues) {
const items: GrnItemBody[] = []
for (const v of values.items) {
const info = itemById(v.itemId)
if (v.acceptedQuantity + v.rejectedQuantity <= 0) {
toast.add({ title: `${info?.sku ?? 'Line'}: enter a quantity.`, type: 'error' })
return
}
const payload = trackingPayload(info, v)
if ('error' in payload) {
toast.add({ title: `${info?.sku ?? 'Line'}: ${payload.error}`, type: 'error' })
return
}
items.push({
itemId: v.itemId,
acceptedQuantity: v.acceptedQuantity,
rejectedQuantity: v.rejectedQuantity,
...payload,
})
}
try {
await grnApi.createDirect({
warehouseId: values.warehouseId,
supplierId: values.supplierId === NO_SUPPLIER ? undefined : values.supplierId,
receivedByUserId: values.receivedByUserId,
items,
})
toast.add({ title: 'Direct receipt recorded', type: 'success' })
router.push('/procurement/grn')
} catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
}
}
return (
<div className="flex max-w-4xl flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Direct Receipt</h1>
<p className="text-sm text-muted-foreground">Receive stock into a warehouse without a purchase order.</p>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="warehouseId"
label="Warehouse"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map(w => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="supplierId"
label="Supplier (optional)"
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="No supplier" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SUPPLIER}>No supplier</SelectItem>
{suppliersList.filter(s => s.isActive).map(s => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<FormField
control={form.control}
name="receivedByUserId"
label="Received By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{usersList.map(user => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">Items</p>
{fields.map((f, index) => {
const v = watchedItems[index]
const info = v ? itemById(v.itemId) : undefined
return (
<div key={f.id} className="grid grid-cols-[1fr_90px_90px_auto] items-start gap-3 rounded-xl border px-3 py-3">
<FormField
control={form.control}
name={`items.${index}.itemId`}
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={val => {
field.onChange(val)
// A different item has different tracking rules — drop stale entries.
form.setValue(`items.${index}.serialText`, '')
form.setValue(`items.${index}.batches`, [])
}}
>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
{itemsList.filter(i => i.isActive).map(i => (
<SelectItem key={i.id} value={i.id}>{i.sku} — {i.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${index}.acceptedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Accepted"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${index}.rejectedQuantity`}
render={({ field, invalid }) => (
<Input
type="number"
min={0}
placeholder="Rejected"
value={Number(field.value as number)}
onChange={e => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<Button
type="button"
variant="ghost"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
Remove
</Button>
{info && v && (
<LineTracking
info={info}
name={info.name}
accepted={v.acceptedQuantity}
serialText={v.serialText}
batches={v.batches}
onSerialText={val => form.setValue(`items.${index}.serialText`, val)}
onBatches={val => form.setValue(`items.${index}.batches`, val, { shouldDirty: true })}
/>
)}
</div>
)
})}
<div>
<Button type="button" variant="outline" onClick={() => append({ ...emptyLine })}>
Add item
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button>
</div>
</form>
</div>
)
}
@@ -1,6 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
@@ -13,31 +16,41 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
import {
grn as grnApi,
purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi,
users as usersApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { PurchaseOrderResponse, WarehouseResponse, UserResponse } from '@/types/api'
import type {
PurchaseOrderResponse,
WarehouseResponse,
UserResponse,
ItemSummaryResponse,
} from '@/types/api'
const batchLineSchema = z.object({
batchNumber: z.string(),
expiryDate: z.string(),
quantity: z.number(),
})
const grnItemSchema = z.object({
purchaseOrderItemId: z.string().min(1),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: z.number().min(0, 'Must be 0 or more'),
// One serial number per line (newline or comma separated) — used for serial-tracked items.
serialText: z.string(),
// Used for batch- and/or expiry-tracked items.
batches: z.array(batchLineSchema),
})
const schema = z.object({
@@ -50,22 +63,20 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() {
return new Date().toISOString().slice(0, 10)
}
export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
export default function NewPoGrnPage() {
const router = useRouter()
const goBack = () => router.push('/procurement/grn')
const [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false)
// Tracking flags per catalogue item, keyed by itemId — the PO line response doesn't carry them.
const [itemInfo, setItemInfo] = useState<Record<string, ItemSummaryResponse>>({})
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -80,11 +91,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' })
// Load reference data when sheet opens
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
// Block Save until every tracked line has its serials/batches fully entered.
const watchedItems = form.watch('items')
const trackingIncomplete = watchedItems.some((v, i) => {
const info = poItems[i] ? itemInfo[poItems[i].itemId] : undefined
const p = trackingProgress(info, v.acceptedQuantity, v.serialText, v.batches)
return p !== null && !p.complete
})
// Load reference data on mount
useEffect(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), receivedByUserId: '', items: [] })
setSelectedPO(null)
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
@@ -100,7 +118,7 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
.catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
)
}, [open])
}, [])
// When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) {
@@ -113,14 +131,20 @@ 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 => ({
const lineItems = activeLines.map(item => ({
purchaseOrderItemId: item.id,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
rejectedQuantity: 0,
serialText: '',
batches: [],
}))
replace(lineItems)
} catch (err) {
@@ -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>
)
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const NAV_GROUPS: NavGroup[] = [
icon: ShoppingCart01Icon,
items: [
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, description: 'Orders placed with suppliers to restock inventory', permission: 'purchase_orders.view' },
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, description: 'Record stock received into a warehouse against a purchase order', permission: 'purchase_orders.view' },
{ title: 'Goods Receipt', href: '/procurement/grn', icon: PackageReceiveIcon, description: 'Record stock received into a warehouse, against a purchase order or directly', permission: 'purchase_orders.view' },
],
},
{
+27 -1
View File
@@ -59,10 +59,24 @@ export const purchaseOrders = {
// ─── Goods Receipts (GRN) ─────────────────────────────────────────────────────
export interface GrnBatchLineBody {
/** Null for an expiry-only lot (item tracks expiry but not batch numbers). */
batchNumber: string | null
expiryDate: string | null
quantity: number
}
export interface GrnItemBody {
purchaseOrderItemId: string
/** Set for a PO receipt; omitted for a direct receipt (itemId is used instead). */
purchaseOrderItemId?: string
/** Set for a direct receipt line. */
itemId?: string
acceptedQuantity: number
rejectedQuantity: number
/** Only for items with TracksBatches and/or TracksExpiryDate. */
batches?: GrnBatchLineBody[]
/** Only for items with TracksSerialNumbers — exactly acceptedQuantity entries. */
serialNumbers?: string[]
}
export interface GrnBody {
@@ -74,6 +88,14 @@ export interface GrnBody {
receivedByUserId: string
}
export interface DirectGrnBody {
warehouseId: string
supplierId?: string
items: GrnItemBody[]
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
receivedByUserId: string
}
export const grn = {
list: (params?: PageParams & { purchaseOrderId?: string }) =>
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
@@ -83,6 +105,10 @@ export const grn = {
create: (body: GrnBody) =>
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data),
/** Receive goods with no purchase order. */
createDirect: (body: DirectGrnBody) =>
api.post<GrnResponse>('/goods-receipts/direct', body).then(r => r.data),
}
// ─── Purchase Returns ─────────────────────────────────────────────────────────
+14 -2
View File
@@ -400,22 +400,34 @@ export interface PurchaseOrderResponse {
items: PurchaseOrderItemResponse[]
}
export interface GrnBatchLineResponse {
batchNumber: string | null
expiryDate: string | null
quantity: number
}
export interface GrnItemResponse {
id: string
purchaseOrderItemId: string
/** Null on a direct receipt line (no purchase order). */
purchaseOrderItemId: string | null
itemId: string
sku: string
itemName: string
acceptedQuantity: number
rejectedQuantity: number
batches: GrnBatchLineResponse[]
serialNumbers: string[]
}
export interface GrnResponse {
id: string
purchaseOrderId: string
/** Null for a direct receipt (goods received without a purchase order). */
purchaseOrderId: string | null
receivedByUserId: string
warehouseId: string
warehouseName: string
supplierId: string | null
supplierName: string | null
receivedAt: string
createdAt: string
items: GrnItemResponse[]