diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx new file mode 100644 index 0000000..77122b0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx @@ -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 + +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([]) + const [brandsList, setBrandsList] = useState([]) + const [uomsList, setUomsList] = useState([]) + const [tracking, setTracking] = useState(NO_TRACKING_ENABLED) + + const form = useForm({ + 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 ( +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + + {(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-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx index b0908c5..17511e4 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx @@ -1,437 +1,40 @@ '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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, - DialogFooter, } from '@/components/ui/dialog' -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' +import type { ItemSummaryResponse } 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 - -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, -} +import { ItemForm } from './item-form' interface ItemSheetProps { open: boolean onClose: () => void - item: ItemSummaryResponse | null + item: ItemSummaryResponse onSuccess: () => void } export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { - const isEdit = !!item - - 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), - 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 ( !v && onClose()}> - {isEdit ? 'Edit Product' : 'Add Product'} - - {isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'} - + Edit Product + Update the product details below. -
- - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - field.onChange(parseFloat(e.target.value) || 0)} - onBlur={field.onBlur} - placeholder="0.00" - aria-invalid={invalid} - /> - )} - /> - - {(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" - /> - )} - /> - )} - - )} - - )} -
- )} - - ( -
- - -
- )} - /> -
- - - - - -
+ { + onSuccess() + onClose() + }} + onCancel={onClose} + />
) 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 27a1fa0..9e047b8 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 @@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) { + + {item.tracksSerialNumbers && Serial} + {item.tracksBatches && Batch} + {item.tracksExpiryDate && Expiry} + {item.hasWarranty && ( + + Warranty {item.allowCustomWarrantyPeriod && item.warrantyPeriodDays != null + ? `${item.warrantyPeriodDays}d` + : '(default period)'} + + )} + + ) : ( + '—' + ) + } + /> {item.isActive ? 'Active' : 'Inactive'}} /> 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 9d11f7c..1f5a72f 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' @@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet' import { ItemViewSheet } from './item-view-sheet' export function ItemsClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -44,11 +46,6 @@ export function ItemsClient() { fetchData(page, pageSize) }, [fetchData, page, pageSize]) - function openCreate() { - setEditing(null) - setSheetOpen(true) - } - function openEdit(item: ItemSummaryResponse) { setEditing(item) setSheetOpen(true) @@ -164,7 +161,7 @@ export function ItemsClient() { Products available for sale and distribution

- @@ -181,12 +178,14 @@ export function ItemsClient() { }} /> - setSheetOpen(false)} - item={editing} - onSuccess={() => fetchData(page, pageSize)} - /> + {editing && ( + setSheetOpen(false)} + item={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + )} router.push('/catalogue/items') + + return ( +
+
+ +
+

Add Product

+

Add a new catalogue product.

+
+
+ +
+ +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx new file mode 100644 index 0000000..85e4a3c --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx @@ -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 ( +
+
+ + {label} + + {description} +
+
+ {checked ? 'ON' : 'OFF'} + onCheckedChange(!!v)} /> +
+
+ ) +} + +/** 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(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 loading ? ( +

Loading...

+ ) : ( + + +
+
+ +
+ Products +
+ + 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. + +
+ +
+ 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. + +
+
+
+ + )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/configuration/page.tsx b/distribution_fe/app/(dashboard)/configuration/page.tsx index e515190..b787920 100644 --- a/distribution_fe/app/(dashboard)/configuration/page.tsx +++ b/distribution_fe/app/(dashboard)/configuration/page.tsx @@ -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 (
@@ -29,26 +10,7 @@ export default function configurationPage() {

System-wide settings, grouped by area

-
- {CATEGORIES.map((category) => ( - - - -
-
- -
- {category.title} -
- {category.description} - - - -
-
- - ))} -
+
) } diff --git a/distribution_fe/app/(dashboard)/configuration/products/page.tsx b/distribution_fe/app/(dashboard)/configuration/products/page.tsx deleted file mode 100644 index 8fdb336..0000000 --- a/distribution_fe/app/(dashboard)/configuration/products/page.tsx +++ /dev/null @@ -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 ( -
-
- - {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. - -
-
-
- - )} -
-
- )} -
- ) -} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx index f3a6a97..ca0259d 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' import { DataTable } from '@/components/shared/data-table' @@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/toast' import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { GrnResponse } from '@/types/api' -import { GrnSheet } from './grn-sheet' import { GrnViewSheet } from './grn-view-sheet' const fmtDate = (v: string) => @@ -18,12 +18,12 @@ const fmtDate = (v: string) => const truncId = (id: string) => id.substring(0, 8) export function GrnClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [sheetOpen, setSheetOpen] = useState(false) const [viewing, setViewing] = useState(null) const [viewOpen, setViewOpen] = useState(false) @@ -71,7 +71,9 @@ export function GrnClient() { fields: ['purchaseOrderId'], width: 140, cellRender: (row) => ( - {truncId(row.purchaseOrderId)} + row.purchaseOrderId + ? {truncId(row.purchaseOrderId)} + : Direct receipt ), }, { @@ -119,13 +121,19 @@ export function GrnClient() {

Goods Receipts

- Stock received into a warehouse against a purchase order + Stock received into a warehouse, against a purchase order or directly

- +
+ + +
- setSheetOpen(false)} - onSuccess={() => fetchData(page, pageSize)} - /> - setViewOpen(false)} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx new file mode 100644 index 0000000..2d31ab6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx @@ -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() + const dupes = new Set() + 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) => + onBatches(batches.map((b, j) => (j === i ? { ...b, ...patch } : b))) + + return ( +
+ + {warrantyNote && {warrantyNote}} + + + + + {isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'} + + {name} ({info.sku}) — accepting {accepted} + + + +
+ {isSerial ? ( + <> + setScanValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + addScanned() + } + }} + /> +
+ setGenPrefix(e.target.value)} + /> + setGenStart(Math.max(1, Number(e.target.value)))} + /> + +
+