From bfd3e5cdedabd8865fa3dcb3061935bea5db67ae Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:07:45 +0530 Subject: [PATCH] add product configuration UI --- .../items/_components/item-sheet.tsx | 163 +++++++++++- .../items/_components/item-view-sheet.tsx | 2 +- .../items/_components/items-client.tsx | 18 +- .../configuration/products/page.tsx | 234 ++++++++++++++++++ 4 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/configuration/products/page.tsx diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx index 7dba95e..b0908c5 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx @@ -23,7 +23,7 @@ import { SelectValue, } from '@/components/ui/select' 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' const schema = z.object({ @@ -33,11 +33,33 @@ const schema = z.object({ 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 +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 { open: boolean onClose: () => void @@ -51,6 +73,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { const [categories, setCategories] = useState([]) const [brandsList, setBrandsList] = useState([]) const [uomsList, setUomsList] = useState([]) + const [tracking, setTracking] = useState(NO_TRACKING_ENABLED) const form = useForm({ resolver: zodResolver(schema), @@ -61,6 +84,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { brandId: undefined, baseUomId: '', salePrice: 0, + tracksSerialNumbers: false, + tracksBatches: false, + tracksExpiryDate: false, + hasWarranty: false, + warrantyPeriodDays: undefined, + allowCustomWarrantyPeriod: false, isActive: true, }, }) @@ -71,10 +100,19 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { categoriesApi.list(), brandsApi.list(), uomsApi.list(), - ]).then(([cats, brandsRes, uomsRes]) => { + 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' }) }) @@ -88,6 +126,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { 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, } : { @@ -97,24 +141,33 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { 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: 'Item updated', type: 'success' }) + toast.add({ title: 'Product updated', type: 'success' }) } else { await itemsApi.create(body) - toast.add({ title: 'Item created', type: 'success' }) + toast.add({ title: 'Product created', type: 'success' }) } onSuccess() onClose() @@ -127,9 +180,9 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { !v && onClose()}> - {isEdit ? 'Edit Item' : 'Add Item'} + {isEdit ? 'Edit Product' : 'Add Product'} - {isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'} + {isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'} @@ -157,7 +210,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { ( + {(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && ( +
+ Tracking + + {tracking.serialNumberTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.batchTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.expiryDateTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.warrantyTracking && ( + <> + ( +
+ + +
+ )} + /> + + {hasWarranty && tracking.customWarrantyOverride && ( + <> + ( +
+ + +
+ )} + /> + + {form.watch('allowCustomWarrantyPeriod') && ( + ( + field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)} + onBlur={field.onBlur} + placeholder="System default" + /> + )} + /> + )} + + )} + + )} +
+ )} + diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx index 1381063..27a1fa0 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx @@ -34,7 +34,7 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) { !v && onClose()}> - Item Details + Product Details
diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx index 607a43c..9d11f7c 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx @@ -34,7 +34,7 @@ export function ItemsClient() { setData(getPaginatedItems(res)) setTotal(res.total) } 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 { setLoading(false) } @@ -62,10 +62,10 @@ export function ItemsClient() { async function handleDelete(item: ItemSummaryResponse) { try { await itemsApi.remove(item.id) - toast.add({ title: 'Item deactivated', type: 'success' }) + toast.add({ title: 'Product deactivated', type: 'success' }) fetchData(page, pageSize) } 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', serverSide: 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] }, columns: [ { @@ -137,17 +137,17 @@ export function ItemsClient() { actions: () => [ { action: 'view', - label: 'View item', + label: 'View product', onClick: openView, }, { action: 'edit', - label: 'Edit item', + label: 'Edit product', onClick: openEdit, }, { action: 'cancel', - label: 'Delete item', + label: 'Delete product', onClick: handleDelete, visible: (target) => target.isActive, }, @@ -159,14 +159,14 @@ export function ItemsClient() { <>
-

Items

+

Products

Products available for sale and distribution

diff --git a/distribution_fe/app/(dashboard)/configuration/products/page.tsx b/distribution_fe/app/(dashboard)/configuration/products/page.tsx new file mode 100644 index 0000000..8fdb336 --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/products/page.tsx @@ -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 ( +
+
+ + {label} + + {description} +
+
+ {checked ? 'ON' : 'OFF'} + onCheckedChange(!!v)} /> +
+
+ ) +} + +export default function ProductsConfigurationPage() { + const { user } = useAuth() + const [loading, setLoading] = useState(true) + const [form, setForm] = useState(DEFAULTS) + const [pendingKeys, setPendingKeys] = useState>({}) + + 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 ( +
+
+ + + Configuration + +
+

Product Configuration

+

+ Control which tracking capabilities are available to products across the catalogue. +

+
+
+ + {loading ? ( +

Loading...

+ ) : ( + + + Product Capabilities + + Each switch applies immediately. Items can then opt into the enabled capabilities from the item's edit form. + + + +
+ updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} + /> + + updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} + /> + + updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} + /> + + updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} + /> +
+ + {form.warrantyTracking && ( + <> + + +
+

+ Warranty defaults +

+ +
+ updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} + /> + +
+ + Default warranty period + +
+ setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))} + onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)} + className="max-w-24" + /> + days +
+ + Applied to items that don't set their own warranty period. + +
+
+
+ + )} +
+
+ )} +
+ ) +}