add product configuration UI
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, getApiError } from '@/lib/api/index'
|
import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
|
||||||
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
|
import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -33,11 +33,33 @@ const schema = z.object({
|
|||||||
brandId: z.string().optional(),
|
brandId: z.string().optional(),
|
||||||
baseUomId: z.string().min(1, 'Base UOM is required'),
|
baseUomId: z.string().min(1, 'Base UOM is required'),
|
||||||
salePrice: z.number().min(0, 'Sale price must be 0 or more'),
|
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(),
|
isActive: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
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
|
||||||
@@ -51,6 +73,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
const [categories, setCategories] = useState<CategoryResponse[]>([])
|
const [categories, setCategories] = useState<CategoryResponse[]>([])
|
||||||
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
|
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
|
||||||
const [uomsList, setUomsList] = useState<UomResponse[]>([])
|
const [uomsList, setUomsList] = useState<UomResponse[]>([])
|
||||||
|
const [tracking, setTracking] = useState<TrackingToggles>(NO_TRACKING_ENABLED)
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -61,6 +84,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
brandId: undefined,
|
brandId: undefined,
|
||||||
baseUomId: '',
|
baseUomId: '',
|
||||||
salePrice: 0,
|
salePrice: 0,
|
||||||
|
tracksSerialNumbers: false,
|
||||||
|
tracksBatches: false,
|
||||||
|
tracksExpiryDate: false,
|
||||||
|
hasWarranty: false,
|
||||||
|
warrantyPeriodDays: undefined,
|
||||||
|
allowCustomWarrantyPeriod: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -71,10 +100,19 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
categoriesApi.list(),
|
categoriesApi.list(),
|
||||||
brandsApi.list(),
|
brandsApi.list(),
|
||||||
uomsApi.list(),
|
uomsApi.list(),
|
||||||
]).then(([cats, brandsRes, uomsRes]) => {
|
sysConfig.getAll(),
|
||||||
|
]).then(([cats, brandsRes, uomsRes, configs]) => {
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
setBrandsList(brandsRes)
|
setBrandsList(brandsRes)
|
||||||
setUomsList(uomsRes)
|
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) => {
|
}).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' })
|
||||||
})
|
})
|
||||||
@@ -88,6 +126,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
brandId: item.brandId ?? undefined,
|
brandId: item.brandId ?? undefined,
|
||||||
baseUomId: item.baseUomId,
|
baseUomId: item.baseUomId,
|
||||||
salePrice: item.salePrice,
|
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,
|
isActive: item.isActive,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -97,24 +141,33 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
brandId: undefined,
|
brandId: undefined,
|
||||||
baseUomId: '',
|
baseUomId: '',
|
||||||
salePrice: 0,
|
salePrice: 0,
|
||||||
|
tracksSerialNumbers: false,
|
||||||
|
tracksBatches: false,
|
||||||
|
tracksExpiryDate: false,
|
||||||
|
hasWarranty: false,
|
||||||
|
warrantyPeriodDays: undefined,
|
||||||
|
allowCustomWarrantyPeriod: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [open, item])
|
}, [open, item])
|
||||||
|
|
||||||
|
const hasWarranty = form.watch('hasWarranty')
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
...values,
|
...values,
|
||||||
brandId: values.brandId || null,
|
brandId: values.brandId || null,
|
||||||
|
warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null,
|
||||||
}
|
}
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await itemsApi.update(item!.id, body)
|
await itemsApi.update(item!.id, body)
|
||||||
toast.add({ title: 'Item updated', type: 'success' })
|
toast.add({ title: 'Product updated', type: 'success' })
|
||||||
} else {
|
} else {
|
||||||
await itemsApi.create(body)
|
await itemsApi.create(body)
|
||||||
toast.add({ title: 'Item created', type: 'success' })
|
toast.add({ title: 'Product created', type: 'success' })
|
||||||
}
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -127,9 +180,9 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
<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 Item' : 'Add Item'}</DialogTitle>
|
<DialogTitle>{isEdit ? 'Edit Product' : 'Add Product'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'}
|
{isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -157,7 +210,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
label="Item Name"
|
label="Product Name"
|
||||||
required
|
required
|
||||||
render={({ field, invalid }) => (
|
render={({ field, invalid }) => (
|
||||||
<Input
|
<Input
|
||||||
@@ -258,6 +311,100 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="isActive"
|
name="isActive"
|
||||||
@@ -281,7 +428,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Item'}
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Product'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) {
|
|||||||
<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>Item Details</DialogTitle>
|
<DialogTitle>Product Details</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ItemsClient() {
|
|||||||
setData(getPaginatedItems(res))
|
setData(getPaginatedItems(res))
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load products', description: getApiError(err), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -62,10 +62,10 @@ export function ItemsClient() {
|
|||||||
async function handleDelete(item: ItemSummaryResponse) {
|
async function handleDelete(item: ItemSummaryResponse) {
|
||||||
try {
|
try {
|
||||||
await itemsApi.remove(item.id)
|
await itemsApi.remove(item.id)
|
||||||
toast.add({ title: 'Item deactivated', type: 'success' })
|
toast.add({ title: 'Product deactivated', type: 'success' })
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to deactivate item', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to deactivate product', description: getApiError(err), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ export function ItemsClient() {
|
|||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
serverSide: true,
|
serverSide: true,
|
||||||
stickyHeader: true,
|
stickyHeader: true,
|
||||||
emptyMessage: 'No items yet. Add one to get started.',
|
emptyMessage: 'No products yet. Add one to get started.',
|
||||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@@ -137,17 +137,17 @@ export function ItemsClient() {
|
|||||||
actions: () => [
|
actions: () => [
|
||||||
{
|
{
|
||||||
action: 'view',
|
action: 'view',
|
||||||
label: 'View item',
|
label: 'View product',
|
||||||
onClick: openView,
|
onClick: openView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit item',
|
label: 'Edit product',
|
||||||
onClick: openEdit,
|
onClick: openEdit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
action: 'cancel',
|
action: 'cancel',
|
||||||
label: 'Delete item',
|
label: 'Delete product',
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
visible: (target) => target.isActive,
|
visible: (target) => target.isActive,
|
||||||
},
|
},
|
||||||
@@ -159,14 +159,14 @@ export function ItemsClient() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Items</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Products</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
Products available for sale and distribution
|
Products available for sale and distribution
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
Add Item
|
Add Product
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user