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