Add item and GRN tracking forms

This commit is contained in:
2026-10-06 13:28:37 +05:30
parent a8725bc473
commit 92f1aadb81
16 changed files with 1536 additions and 764 deletions
@@ -0,0 +1,220 @@
'use client'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Package01Icon } from '@hugeicons/core-free-icons'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { FieldLabel, FieldDescription } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { useAuth } from '@/contexts/auth-context'
interface FormState {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
defaultWarrantyPeriodDays: number
}
const DEFAULTS: FormState = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
defaultWarrantyPeriodDays: 0,
}
interface CapabilityToggleProps {
id: string
label: string
description: string
checked: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
className?: string
}
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
className,
)}
>
<div className="flex flex-col gap-0.5">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</FieldLabel>
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
</div>
</div>
)
}
/** The Products configuration card: tracking capability switches and warranty defaults, saved immediately. */
export function ProductCapabilitiesCard() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormState>(DEFAULTS)
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
useEffect(() => {
sysConfig.getAll()
.then((configs) => {
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setForm({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
})
})
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
const previous = form[formKey]
setForm(f => ({ ...f, [formKey]: value }))
setPendingKeys(p => ({ ...p, [configKey]: true }))
try {
await sysConfig.set(configKey, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, [formKey]: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [configKey]: false }))
}
}
async function updateDefaultWarrantyDays(value: number) {
const previous = form.defaultWarrantyPeriodDays
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
if (value === previous) return
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
try {
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
}
}
return loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Card className="max-w-4xl rounded-3xl border-border/60">
<CardHeader className="border-b border-border/60 pb-5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={Package01Icon} size={18} />
</div>
<CardTitle className="text-lg">Products</CardTitle>
</div>
<CardDescription className="mt-3">
Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately;
items then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader>
<CardContent className="pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="serialNumberTracking"
label="Serial number tracking"
description="Track individual units of an item by a unique serial number."
checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
/>
<CapabilityToggle
id="batchTracking"
label="Batch / lot tracking"
description="Group stock into batches or lots for traceability."
checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
/>
<CapabilityToggle
id="expiryDateTracking"
label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock."
checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
/>
<CapabilityToggle
id="warrantyTracking"
label="Warranty periods"
description="Attach a warranty period to items at the time of sale."
checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
/>
</div>
{form.warrantyTracking && (
<>
<Separator className="my-5" />
<div className="flex flex-col gap-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="customWarrantyOverride"
label="Per-item custom warranty"
description="Allow individual items to override the default warranty period."
checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/>
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="defaultWarrantyPeriodDays"
type="number"
min={0}
value={String(form.defaultWarrantyPeriodDays)}
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
className="max-w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<FieldDescription className="text-xs">
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
)
}
@@ -1,27 +1,8 @@
'use client'
import Link from 'next/link'
import { HugeiconsIcon } from '@hugeicons/react'
import { Package01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from '@/components/ui/card'
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
interface ConfigCategory {
title: string
description: string
href: string
icon: typeof Package01Icon
}
const CATEGORIES: ConfigCategory[] = [
{
title: 'Products',
description: 'Serial number, batch, expiry and warranty tracking for catalogue items',
href: '/configuration/products',
icon: Package01Icon,
},
]
export default function configurationPage() {
export default function ConfigurationPage() {
return (
<div className="flex flex-col gap-6">
<div>
@@ -29,26 +10,7 @@ export default function configurationPage() {
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{CATEGORIES.map((category) => (
<Link key={category.href} href={category.href} className="block">
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={category.icon} size={18} />
</div>
<CardTitle>{category.title}</CardTitle>
</div>
<CardDescription className="mt-3">{category.description}</CardDescription>
<CardAction>
<HugeiconsIcon icon={ArrowRight01Icon} size={16} className="text-muted-foreground" />
</CardAction>
</CardHeader>
</Card>
</Link>
))}
</div>
<ProductCapabilitiesCard />
</div>
)
}
@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { FieldLabel, FieldDescription } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast'
import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index'
import { cn } from '@/lib/utils'
import { useAuth } from '@/contexts/auth-context'
interface FormState {
serialNumberTracking: boolean
batchTracking: boolean
expiryDateTracking: boolean
warrantyTracking: boolean
customWarrantyOverride: boolean
defaultWarrantyPeriodDays: number
}
const DEFAULTS: FormState = {
serialNumberTracking: false,
batchTracking: false,
expiryDateTracking: false,
warrantyTracking: false,
customWarrantyOverride: false,
defaultWarrantyPeriodDays: 0,
}
interface CapabilityToggleProps {
id: string
label: string
description: string
checked: boolean
disabled?: boolean
onCheckedChange: (checked: boolean) => void
className?: string
}
function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4',
className,
)}
>
<div className="flex flex-col gap-0.5">
<FieldLabel htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</FieldLabel>
<FieldDescription className="text-xs leading-relaxed">{description}</FieldDescription>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-xs font-medium text-muted-foreground">{checked ? 'ON' : 'OFF'}</span>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={(v) => onCheckedChange(!!v)} />
</div>
</div>
)
}
export default function ProductsConfigurationPage() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [form, setForm] = useState<FormState>(DEFAULTS)
const [pendingKeys, setPendingKeys] = useState<Record<string, boolean>>({})
useEffect(() => {
sysConfig.getAll()
.then((configs) => {
const byKey = Object.fromEntries(configs.map(c => [c.key, c.value]))
setForm({
serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true',
batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true',
expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true',
warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true',
customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true',
defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0),
})
})
.catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) {
const previous = form[formKey]
setForm(f => ({ ...f, [formKey]: value }))
setPendingKeys(p => ({ ...p, [configKey]: true }))
try {
await sysConfig.set(configKey, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, [formKey]: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [configKey]: false }))
}
}
async function updateDefaultWarrantyDays(value: number) {
const previous = form.defaultWarrantyPeriodDays
setForm(f => ({ ...f, defaultWarrantyPeriodDays: value }))
if (value === previous) return
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true }))
try {
await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id)
} catch (err) {
setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous }))
toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' })
} finally {
setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false }))
}
}
return (
<div className="flex flex-col gap-8 bg-muted/30">
<div className="flex flex-col gap-3">
<Link
href="/configuration"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={14} />
Configuration
</Link>
<div>
<h1 className="text-3xl font-semibold tracking-tight text-foreground">Product Configuration</h1>
<p className="mt-1 text-sm text-muted-foreground">
Control which tracking capabilities are available to products across the catalogue.
</p>
</div>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Card className="max-w-4xl rounded-3xl border-border/60">
<CardHeader className="border-b border-border/60 pb-5">
<CardTitle className="text-lg">Product Capabilities</CardTitle>
<CardDescription>
Each switch applies immediately. Items can then opt into the enabled capabilities from the item&apos;s edit form.
</CardDescription>
</CardHeader>
<CardContent className="pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="serialNumberTracking"
label="Serial number tracking"
description="Track individual units of an item by a unique serial number."
checked={form.serialNumberTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.serialNumberTracking]}
onCheckedChange={(v) => updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)}
/>
<CapabilityToggle
id="batchTracking"
label="Batch / lot tracking"
description="Group stock into batches or lots for traceability."
checked={form.batchTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.batchTracking]}
onCheckedChange={(v) => updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)}
/>
<CapabilityToggle
id="expiryDateTracking"
label="Expiry date tracking"
description="Record and monitor expiry dates on perishable stock."
checked={form.expiryDateTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.expiryDateTracking]}
onCheckedChange={(v) => updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)}
/>
<CapabilityToggle
id="warrantyTracking"
label="Warranty periods"
description="Attach a warranty period to items at the time of sale."
checked={form.warrantyTracking}
disabled={pendingKeys[ITEM_TRACKING_KEYS.warrantyTracking]}
onCheckedChange={(v) => updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)}
/>
</div>
{form.warrantyTracking && (
<>
<Separator className="my-5" />
<div className="flex flex-col gap-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Warranty defaults
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CapabilityToggle
id="customWarrantyOverride"
label="Per-item custom warranty"
description="Allow individual items to override the default warranty period."
checked={form.customWarrantyOverride}
disabled={pendingKeys[ITEM_TRACKING_KEYS.customWarrantyOverride]}
onCheckedChange={(v) => updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)}
/>
<div className="flex flex-col justify-center gap-2 rounded-2xl border border-border/60 bg-background p-4">
<FieldLabel htmlFor="defaultWarrantyPeriodDays" className="text-sm font-medium text-foreground">
Default warranty period
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="defaultWarrantyPeriodDays"
type="number"
min={0}
value={String(form.defaultWarrantyPeriodDays)}
onChange={(e) => setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))}
onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)}
className="max-w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<FieldDescription className="text-xs">
Applied to items that don&apos;t set their own warranty period.
</FieldDescription>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)
}