add product configuration UI
This commit is contained in:
@@ -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