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' '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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field' import type { ItemSummaryResponse } from '@/types/api'
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({ import { ItemForm } from './item-form'
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,
}
interface ItemSheetProps { interface ItemSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
item: ItemSummaryResponse | null item: ItemSummaryResponse
onSuccess: () => void onSuccess: () => void
} }
export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? 'Edit Product' : 'Add Product'}</DialogTitle> <DialogTitle>Edit Product</DialogTitle>
<DialogDescription> <DialogDescription>Update the product details below.</DialogDescription>
{isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <ItemForm
<FieldGroup> item={item}
<FormField onSuccess={() => {
control={form.control} onSuccess()
name="sku" onClose()
label="SKU" }}
required onCancel={onClose}
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">
<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> </DialogContent>
</Dialog> </Dialog>
) )
@@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
<Row label="Brand" value={item.brandName ?? '—'} /> <Row label="Brand" value={item.brandName ?? '—'} />
<Row label="Base UOM" value={item.baseUomName} /> <Row label="Base UOM" value={item.baseUomName} />
<Row label="Sale Price" value={`Rs. ${item.salePrice?.toFixed(2) ?? '0.00'}`} /> <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="Sync Source" value={item.syncSource} />
<Row label="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} /> <Row label="Status" value={<Badge variant={item.isActive ? 'default' : 'secondary'}>{item.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Created At" value={fmtDate(item.createdAt)} /> <Row label="Created At" value={fmtDate(item.createdAt)} />
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet'
import { ItemViewSheet } from './item-view-sheet' import { ItemViewSheet } from './item-view-sheet'
export function ItemsClient() { export function ItemsClient() {
const router = useRouter()
const [data, setData] = useState<ItemSummaryResponse[]>([]) const [data, setData] = useState<ItemSummaryResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -44,11 +46,6 @@ export function ItemsClient() {
fetchData(page, pageSize) fetchData(page, pageSize)
}, [fetchData, page, pageSize]) }, [fetchData, page, pageSize])
function openCreate() {
setEditing(null)
setSheetOpen(true)
}
function openEdit(item: ItemSummaryResponse) { function openEdit(item: ItemSummaryResponse) {
setEditing(item) setEditing(item)
setSheetOpen(true) setSheetOpen(true)
@@ -164,7 +161,7 @@ export function ItemsClient() {
Products available for sale and distribution Products available for sale and distribution
</p> </p>
</div> </div>
<Button onClick={openCreate}> <Button onClick={() => router.push('/catalogue/items/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} /> <HugeiconsIcon icon={Add01Icon} size={16} />
Add Product Add Product
</Button> </Button>
@@ -181,12 +178,14 @@ export function ItemsClient() {
}} }}
/> />
<ItemSheet {editing && (
open={sheetOpen} <ItemSheet
onClose={() => setSheetOpen(false)} open={sheetOpen}
item={editing} onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)} item={editing}
/> onSuccess={() => fetchData(page, pageSize)}
/>
)}
<ItemViewSheet <ItemViewSheet
open={viewOpen} 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' 'use client'
import Link from 'next/link' import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
import { HugeiconsIcon } from '@hugeicons/react'
import { Package01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from '@/components/ui/card'
interface ConfigCategory { export default function ConfigurationPage() {
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() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <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> <p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <ProductCapabilitiesCard />
{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>
</div> </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' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table' import { DataTable } from '@/components/shared/data-table'
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { GrnResponse } from '@/types/api' import type { GrnResponse } from '@/types/api'
import { GrnSheet } from './grn-sheet'
import { GrnViewSheet } from './grn-view-sheet' import { GrnViewSheet } from './grn-view-sheet'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
@@ -18,12 +18,12 @@ const fmtDate = (v: string) =>
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
export function GrnClient() { export function GrnClient() {
const router = useRouter()
const [data, setData] = useState<GrnResponse[]>([]) const [data, setData] = useState<GrnResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [viewing, setViewing] = useState<GrnResponse | null>(null) const [viewing, setViewing] = useState<GrnResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
@@ -71,7 +71,9 @@ export function GrnClient() {
fields: ['purchaseOrderId'], fields: ['purchaseOrderId'],
width: 140, width: 140,
cellRender: (row) => ( 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> <div>
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1> <h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <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> </p>
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <div className="flex items-center gap-2">
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} /> <Button variant="outline" onClick={() => router.push('/procurement/grn/direct/new')}>
Record Receipt <HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
</Button> Direct Receipt
</Button>
<Button onClick={() => router.push('/procurement/grn/po/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} strokeWidth={2} />
Receipt Against PO
</Button>
</div>
</div> </div>
<DataTable <DataTable
@@ -139,12 +147,6 @@ export function GrnClient() {
}} }}
/> />
<GrnSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<GrnViewSheet <GrnViewSheet
open={viewOpen} open={viewOpen}
onClose={() => setViewOpen(false)} 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', 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 { interface GrnViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -40,7 +43,10 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
</DialogHeader> </DialogHeader>
<div className="flex flex-col"> <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="Warehouse" value={grn.warehouseName} />
<Row label="Received At" value={fmtDateTime(grn.receivedAt)} /> <Row label="Received At" value={fmtDateTime(grn.receivedAt)} />
<Row label="Created" value={fmtDateTime(grn.createdAt)} /> <Row label="Created" value={fmtDateTime(grn.createdAt)} />
@@ -65,6 +71,26 @@ export function GrnViewSheet({ open, onClose, grn }: GrnViewSheetProps) {
<td className="px-3 py-2"> <td className="px-3 py-2">
{item.itemName} {item.itemName}
<span className="ml-1 text-xs text-muted-foreground">({item.sku})</span> <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>
<td className="px-3 py-2 text-right">{item.acceptedQuantity}</td> <td className="px-3 py-2 text-right">{item.acceptedQuantity}</td>
<td className="px-3 py-2 text-right">{item.rejectedQuantity}</td> <td className="px-3 py-2 text-right">{item.rejectedQuantity}</td>
@@ -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' 'use client'
import { useEffect, useState } from 'react' 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 { useFieldArray } from 'react-hook-form'
import { useForm, zodResolver, z } from '@/lib/form' import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field' import { FormField } from '@/components/shared/form-field'
@@ -13,31 +16,41 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import type { GrnItemBody } from '@/lib/api/procurement'
import { LineTracking, trackingPayload, trackingProgress } from '../../_components/grn-tracking'
import { import {
grn as grnApi, grn as grnApi,
purchaseOrders as purchaseOrdersApi, purchaseOrders as purchaseOrdersApi,
warehouses as warehousesApi, warehouses as warehousesApi,
users as usersApi, users as usersApi,
items as itemsApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } 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({ const grnItemSchema = z.object({
purchaseOrderItemId: z.string().min(1), purchaseOrderItemId: z.string().min(1),
acceptedQuantity: z.number().min(0, 'Must be 0 or more'), acceptedQuantity: z.number().min(0, 'Must be 0 or more'),
rejectedQuantity: 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({ const schema = z.object({
@@ -50,22 +63,20 @@ const schema = z.object({
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
interface GrnSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
function todayIso() { function todayIso() {
return new Date().toISOString().slice(0, 10) 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 [eligiblePOs, setEligiblePOs] = useState<PurchaseOrderResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([]) const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null) const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null)
const [loadingPO, setLoadingPO] = useState(false) 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>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -80,11 +91,18 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
const { fields, replace } = useFieldArray({ control: form.control, name: 'items' }) 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(() => { useEffect(() => {
if (!open) return
form.reset({ purchaseOrderId: '', warehouseId: '', receivedAt: todayIso(), receivedByUserId: '', items: [] })
setSelectedPO(null)
Promise.all([ Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }), purchaseOrdersApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 }),
@@ -100,7 +118,7 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
.catch(err => .catch(err =>
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
) )
}, [open]) }, [])
// When PO changes, fetch its details and populate items // When PO changes, fetch its details and populate items
async function handlePOChange(poId: string) { async function handlePOChange(poId: string) {
@@ -113,15 +131,21 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
setLoadingPO(true) setLoadingPO(true)
try { try {
const po = await purchaseOrdersApi.get(poId) 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) setSelectedPO(po)
// Pre-fill items: remaining quantity for each non-cancelled line // Pre-fill items: remaining quantity for each non-cancelled line
const lineItems = (po.items ?? []) const lineItems = activeLines.map(item => ({
.filter(item => !item.isCancelled) purchaseOrderItemId: item.id,
.map(item => ({ acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity),
purchaseOrderItemId: item.id, rejectedQuantity: 0,
acceptedQuantity: Math.max(0, item.quantity - item.receivedQuantity), serialText: '',
rejectedQuantity: 0, batches: [],
})) }))
replace(lineItems) replace(lineItems)
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to load PO details', description: getApiError(err), type: 'error' }) 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) { async function onSubmit(values: FormValues) {
const items = buildItems(values, poItems)
if (!items) return
try { try {
await grnApi.create({ await grnApi.create({
purchaseOrderId: values.purchaseOrderId, purchaseOrderId: values.purchaseOrderId,
warehouseId: values.warehouseId, warehouseId: values.warehouseId,
receivedAt: `${values.receivedAt}T00:00:00.000Z`, receivedAt: `${values.receivedAt}T00:00:00.000Z`,
items: values.items, items,
receivedByUserId: values.receivedByUserId, receivedByUserId: values.receivedByUserId,
}) })
toast.add({ title: 'Goods receipt recorded', type: 'success' }) toast.add({ title: 'Goods receipt recorded', type: 'success' })
onSuccess() router.push('/procurement/grn')
onClose()
} catch (err) { } catch (err) {
toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to record receipt', description: getApiError(err), type: 'error' })
} }
} }
const poItems = selectedPO?.items?.filter(i => !i.isCancelled) ?? []
return ( return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}> <div className="flex max-w-4xl flex-col gap-6">
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl"> <div className="flex items-center gap-4">
<DialogHeader> <Button type="button" variant="outline" size="sm" onClick={goBack}>
<DialogTitle>Record Goods Receipt</DialogTitle> <HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
<DialogDescription> Back
Record stock received into a warehouse against a purchase order. </Button>
</DialogDescription> <div>
</DialogHeader> <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> <FieldGroup>
<FormField <FormField
control={form.control} 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> </div>
) )
})} })}
@@ -313,14 +371,13 @@ export function GrnSheet({ open, onClose, onSuccess }: GrnSheetProps) {
</> </>
)} )}
<DialogFooter> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={goBack}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0}> <Button type="submit" disabled={form.formState.isSubmitting || fields.length === 0 || trackingIncomplete}>
{form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'} {form.formState.isSubmitting ? 'Saving…' : 'Record Receipt'}
</Button> </Button>
</DialogFooter> </div>
</form> </form>
</DialogContent> </div>
</Dialog>
) )
} }
+1 -1
View File
@@ -107,7 +107,7 @@ export const NAV_GROUPS: NavGroup[] = [
icon: ShoppingCart01Icon, icon: ShoppingCart01Icon,
items: [ items: [
{ title: 'Purchase Orders', href: '/procurement/orders', icon: ShoppingCart01Icon, description: 'Orders placed with suppliers to restock inventory', permission: 'purchase_orders.view' }, { 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) ───────────────────────────────────────────────────── // ─── 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 { 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 acceptedQuantity: number
rejectedQuantity: 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 { export interface GrnBody {
@@ -74,6 +88,14 @@ export interface GrnBody {
receivedByUserId: string 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 = { export const grn = {
list: (params?: PageParams & { purchaseOrderId?: string }) => list: (params?: PageParams & { purchaseOrderId?: string }) =>
api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data), api.get<PaginatedResponse<GrnResponse>>('/goods-receipts', { params }).then(r => r.data),
@@ -83,6 +105,10 @@ export const grn = {
create: (body: GrnBody) => create: (body: GrnBody) =>
api.post<GrnResponse>('/goods-receipts', body).then(r => r.data), 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 ───────────────────────────────────────────────────────── // ─── Purchase Returns ─────────────────────────────────────────────────────────
+14 -2
View File
@@ -400,22 +400,34 @@ export interface PurchaseOrderResponse {
items: PurchaseOrderItemResponse[] items: PurchaseOrderItemResponse[]
} }
export interface GrnBatchLineResponse {
batchNumber: string | null
expiryDate: string | null
quantity: number
}
export interface GrnItemResponse { export interface GrnItemResponse {
id: string id: string
purchaseOrderItemId: string /** Null on a direct receipt line (no purchase order). */
purchaseOrderItemId: string | null
itemId: string itemId: string
sku: string sku: string
itemName: string itemName: string
acceptedQuantity: number acceptedQuantity: number
rejectedQuantity: number rejectedQuantity: number
batches: GrnBatchLineResponse[]
serialNumbers: string[]
} }
export interface GrnResponse { export interface GrnResponse {
id: string id: string
purchaseOrderId: string /** Null for a direct receipt (goods received without a purchase order). */
purchaseOrderId: string | null
receivedByUserId: string receivedByUserId: string
warehouseId: string warehouseId: string
warehouseName: string warehouseName: string
supplierId: string | null
supplierName: string | null
receivedAt: string receivedAt: string
createdAt: string createdAt: string
items: GrnItemResponse[] items: GrnItemResponse[]