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