toggle screen for product configurations

This commit is contained in:
2026-07-17 11:34:46 +05:30
parent 7c5faabc2d
commit 47683ddd0f
12 changed files with 468 additions and 27 deletions
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
@@ -13,7 +13,8 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" import { useProductConfig } from "@/hooks/use-product-config"
import { Category, Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -49,10 +50,12 @@ export default function ItemDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
const itemId = Number(params.id) const itemId = Number(params.id)
const { config } = useProductConfig()
const subcategoriesEnabled = !!config?.subcategories
const [item, setItem] = useState<Item | null>(null) const [item, setItem] = useState<Item | null>(null)
const [etag, setEtag] = useState<string | null>(null) const [etag, setEtag] = useState<string | null>(null)
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([]) const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
@@ -63,6 +66,7 @@ export default function ItemDetailPage() {
const [name, setName] = useState("") const [name, setName] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null) const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked") const [itemType, setItemType] = useState<ItemType>("Stocked")
@@ -117,7 +121,7 @@ export default function ItemDetailPage() {
useEffect(() => { useEffect(() => {
if (!Number.isFinite(itemId)) return if (!Number.isFinite(itemId)) return
load() load()
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([categoriesApi.list({ pageSize: 200 }), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([cat, uo, ve, wh]) => { .then(([cat, uo, ve, wh]) => {
setCategories(cat.items) setCategories(cat.items)
setUoms(uo.items) setUoms(uo.items)
@@ -128,10 +132,42 @@ export default function ItemDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId]) }, [itemId])
// Once both the item and the full category list are in, split a subcategory
// item's categoryId into its top-level Category + Subcategory pair for the UI.
useEffect(() => {
if (!item || categories.length === 0) return
if (!subcategoriesEnabled) {
setSubCategoryId(null)
return
}
const current = categories.find((c) => c.categoryId === item.categoryId)
if (current && current.parentId !== null) {
setCategoryId(current.parentId)
setSubCategoryId(current.categoryId)
} else {
setSubCategoryId(null)
}
}, [item, categories, subcategoriesEnabled])
const topCategories = useMemo(
() => (subcategoriesEnabled ? categories.filter((c) => c.parentId === null) : categories),
[categories, subcategoriesEnabled]
)
const subCategoryOptions = useMemo(
() => categories.filter((c) => c.parentId === categoryId),
[categories, categoryId]
)
const effectiveCategoryId = subcategoriesEnabled ? (subCategoryId ?? categoryId) : categoryId
function handleCategoryChange(value: number | null) {
setCategoryId(value)
setSubCategoryId(null)
}
async function handleSave() { async function handleSave() {
if (!item || !etag) return if (!item || !etag) return
setSaveError(null) setSaveError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) const nextErrors = validateItemForm({ sku, name, categoryId: effectiveCategoryId, baseUomId })
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
@@ -139,7 +175,7 @@ export default function ItemDetailPage() {
try { try {
const result = await itemsApi.update( const result = await itemsApi.update(
item.itemId, item.itemId,
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, { sku, name, description: description || null, categoryId: effectiveCategoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
etag etag
) )
applyItem(result.data) applyItem(result.data)
@@ -339,12 +375,12 @@ export default function ItemDetailPage() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Category</Label> <Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}> <Select<number | null> value={categoryId} onValueChange={handleCategoryChange} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}> <SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder="Select category" /> <SelectValue placeholder="Select category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categories.map((c) => ( {topCategories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base"> <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name} {c.name}
</SelectItem> </SelectItem>
@@ -353,6 +389,27 @@ export default function ItemDetailPage() {
</Select> </Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} /> <FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div> </div>
{subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
disabled={conflict || subCategoryOptions.length === 0}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategoryOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label> <Label className="text-base">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> <Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
@@ -8,6 +8,7 @@ import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data" import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useProductConfig } from "@/hooks/use-product-config"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data" import { Category } from "@/types/master-data"
@@ -26,10 +27,17 @@ type SortOrder = "asc" | "desc"
const PAGE_SIZE = 5 const PAGE_SIZE = 5
export default function CategoriesPage() { export default function CategoriesPage() {
const { config } = useProductConfig()
const subcategoriesEnabled = !!config?.subcategories
const [categories, setCategories] = useState<Category[] | null>(null) const [categories, setCategories] = useState<Category[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Unfiltered/unpaginated copy used to populate the parent-category picker and to
// resolve a row's parent name regardless of which page it's showing.
const [allCategories, setAllCategories] = useState<Category[]>([])
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc") const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
@@ -38,6 +46,7 @@ export default function CategoriesPage() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null) const [editing, setEditing] = useState<Category | null>(null)
const [name, setName] = useState("") const [name, setName] = useState("")
const [parentId, setParentId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
@@ -62,13 +71,28 @@ export default function CategoriesPage() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
function loadAllCategories() {
categoriesApi
.list({ pageSize: 500 })
.then((res) => setAllCategories(res.items))
.catch(() => {})
}
useEffect(load, [search, sortOrder, page]) useEffect(load, [search, sortOrder, page])
useEffect(loadAllCategories, [])
const hasFilters = search.trim().length > 0 const hasFilters = search.trim().length > 0
const parentOptions = allCategories.filter((c) => c.parentId === null && c.categoryId !== editing?.categoryId)
function parentName(category: Category): string {
if (category.parentId === null) return "—"
return allCategories.find((c) => c.categoryId === category.parentId)?.name ?? `#${category.parentId}`
}
function openCreateDialog() { function openCreateDialog() {
setEditing(null) setEditing(null)
setName("") setName("")
setParentId(null)
setErrors({}) setErrors({})
setOpen(true) setOpen(true)
} }
@@ -76,6 +100,7 @@ export default function CategoriesPage() {
function openEditDialog(category: Category) { function openEditDialog(category: Category) {
setEditing(category) setEditing(category)
setName(category.name) setName(category.name)
setParentId(category.parentId)
setErrors({}) setErrors({})
setOpen(true) setOpen(true)
} }
@@ -88,14 +113,16 @@ export default function CategoriesPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const category = editing const category = editing
? await categoriesApi.update(editing.categoryId, { name }) ? await categoriesApi.update(editing.categoryId, { name, parentId: subcategoriesEnabled ? parentId : null })
: await categoriesApi.create({ name }) : await categoriesApi.create({ name, parentId: subcategoriesEnabled ? parentId : null })
toast.success(editing ? "Category updated" : "Category created", category.name) toast.success(editing ? "Category updated" : "Category created", category.name)
setOpen(false) setOpen(false)
setName("") setName("")
setParentId(null)
setEditing(null) setEditing(null)
setErrors({}) setErrors({})
load() load()
loadAllCategories()
} catch (err) { } catch (err) {
setErrors({ name: errorMessage(err) }) setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
@@ -135,7 +162,9 @@ export default function CategoriesPage() {
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle> <DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription> <DialogDescription>
{subcategoriesEnabled ? "Give the category a name and, optionally, a parent." : "Give the category a name."}
</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
@@ -143,6 +172,23 @@ export default function CategoriesPage() {
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> <Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field> </Field>
{subcategoriesEnabled && (
<Field>
<FieldLabel htmlFor="cat-parent">Parent category (optional)</FieldLabel>
<Select<number | null> value={parentId} onValueChange={setParentId}>
<SelectTrigger id="cat-parent" className="w-full">
<SelectValue placeholder="None (top-level category)" />
</SelectTrigger>
<SelectContent>
{parentOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
@@ -206,6 +252,7 @@ export default function CategoriesPage() {
<TableRow className="hover:bg-indigo-50"> <TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
{subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm text-indigo-700">Parent</TableHead>}
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow> </TableRow>
@@ -215,6 +262,9 @@ export default function CategoriesPage() {
<TableRow key={c.categoryId}> <TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell> <TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
{subcategoriesEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">{parentName(c)}</TableCell>
)}
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -0,0 +1,205 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Boxes, Package, Tag } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/settings"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
interface ToggleField {
key: keyof ProductConfig
label: string
description: string
}
interface Section {
title: string
icon: typeof Package
fields: ToggleField[]
}
const SECTIONS: Section[] = [
{
title: "Product Capabilities",
icon: Package,
fields: [
{
key: "subcategories",
label: "Subcategories",
description: "Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a main category.",
},
{ key: "brands", label: "Brands", description: "Products may carry a brand." },
{ key: "productImages", label: "Product images", description: "Enable image upload on products." },
{
key: "serialNumbers",
label: "Serial numbers",
description: "Track individual units by serial number (captured at receipt, selected at sale).",
},
{ key: "batchLotTracking", label: "Batch / lot tracking", description: "Maintain stock batch/lot-wise." },
{
key: "expiryMfgDates",
label: "Expiry / mfg dates",
description: "Track expiry & manufacture dates (typically implies batch; drives FEFO).",
},
{
key: "warranty",
label: "Warranty",
description: "Capture warranty period/terms (independent of serial & batch).",
},
{ key: "serviceItems", label: "Service items", description: "Sell non-stock service items (no stock/costing)." },
{
key: "adHocSaleLines",
label: "Ad-hoc sale lines",
description: "Allow non-inventory typed lines (name + price) on sales/quotations.",
},
],
},
{
title: "Pricing & Quantity",
icon: Tag,
fields: [
{
key: "minPriceFloor",
label: "Minimum price floor",
description: "Enforce a per-variant minimum sale price (even after discount).",
},
{
key: "maxPriceCeiling",
label: "Maximum price ceiling",
description: "Enforce a per-variant maximum sale price.",
},
{
key: "freePricingProducts",
label: "Free-pricing products",
description: "Products may be flagged free-pricing (operator sets any price, bypasses min/max).",
},
{
key: "fractionalQuantities",
label: "Fractional quantities",
description: "Allow fractional quantities (e.g. 1.5 kg) where the unit permits.",
},
{
key: "packConversion",
label: "Pack conversion (UoM)",
description: "Per-product pack units (buy Box / sell Piece). Stock is always stored in one base unit.",
},
],
},
{
title: "Stock Behaviour",
icon: Boxes,
fields: [
{
key: "allowNegativeStock",
label: "Allow negative stock",
description:
"Company default for allowing negative stock (a product may narrow). Costed items are blocked from going negative regardless.",
},
],
},
]
export default function ProductConfigurationPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
productConfigApi
.get()
.then(setConfig)
.catch((err) => setError(errorMessage(err)))
}, [])
function toggle(key: keyof ProductConfig, checked: boolean) {
setConfig((prev) => (prev ? { ...prev, [key]: checked } : prev))
}
async function handleSave() {
if (!config) return
setSaving(true)
try {
const saved = await productConfigApi.update(config)
setConfig(saved)
toast.success("Product configuration saved")
} catch (err) {
toast.error("Could not save product configuration", errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground">
Company-wide toggles that control which product & category capabilities are available.
</p>
</div>
</div>
<Button size="lg" onClick={handleSave} disabled={!config || saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && (
<div className="flex flex-col gap-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{config && (
<div className="flex flex-col gap-6">
{SECTIONS.map((section) => (
<div key={section.title} className="flex flex-col gap-5 rounded-2xl bg-card p-6 ring-1 ring-foreground/10">
<div className="flex items-center gap-2.5 border-b pb-4">
<section.icon className="size-5 text-indigo-600" />
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{section.fields.map((field) => (
<label
key={field.key}
htmlFor={field.key}
className="flex cursor-pointer items-start gap-3"
>
<Switch
id={field.key}
checked={config[field.key]}
onCheckedChange={(checked) => toggle(field.key, checked)}
className={cn("mt-0.5")}
/>
<span className="flex flex-col gap-0.5">
<span className="text-base font-semibold text-foreground">{field.label}</span>
<span className="text-sm text-muted-foreground">{field.description}</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -12,6 +12,7 @@ import { variantCategoriesApi } from "@/lib/api/variants"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data" import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useProductConfig } from "@/hooks/use-product-config"
import { Category, VariantCategory } from "@/types/master-data" import { Category, VariantCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -57,6 +58,8 @@ const DEFAULT_BASE_UOM_ID = 1
export default function NewItemPage() { export default function NewItemPage() {
const router = useRouter() const router = useRouter()
const { config } = useProductConfig()
const subcategoriesEnabled = !!config?.subcategories
const [categories, setCategories] = useState<Category[] | null>(null) const [categories, setCategories] = useState<Category[] | null>(null)
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
@@ -92,7 +95,10 @@ export default function NewItemPage() {
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories]) const topCategories = useMemo(
() => (subcategoriesEnabled ? (categories ?? []).filter((c) => c.parentId === null) : (categories ?? [])),
[categories, subcategoriesEnabled]
)
const subCategoryOptions = useMemo( const subCategoryOptions = useMemo(
() => (categories ?? []).filter((c) => c.parentId === categoryId), () => (categories ?? []).filter((c) => c.parentId === categoryId),
[categories, categoryId] [categories, categoryId]
@@ -259,6 +265,7 @@ export default function NewItemPage() {
</Select> </Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} /> <FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div> </div>
{subcategoriesEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label> <Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> <Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
@@ -274,6 +281,7 @@ export default function NewItemPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label> <Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}> <Select<number | null> value={brandId} onValueChange={setBrandId}>
@@ -15,6 +15,7 @@ import {
Package, Package,
PackageCheck, PackageCheck,
Settings, Settings,
SlidersHorizontal,
ShoppingCart, ShoppingCart,
SwatchBook, SwatchBook,
Tag, Tag,
@@ -44,6 +45,7 @@ const navItems: {
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree }, { title: "Category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag }, { title: "Brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook }, { title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook },
{ title: "Product Configuration", href: "/dashboard/products/configuration", icon: SlidersHorizontal },
], ],
}, },
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
@@ -0,0 +1,25 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-input transition-colors outline-none focus-visible:ring-3 focus-visible:ring-purple-500/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-purple-600 dark:bg-input/60",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block size-5 translate-x-0.5 rounded-full bg-background shadow-sm ring-0 transition-transform data-checked:translate-x-5.5 dark:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
@@ -0,0 +1,21 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { productConfigApi } from "@/lib/api/product-config"
import { ProductConfig } from "@/types/settings"
/** Fetches the company's Product Configuration once on mount. `null` while loading. */
export function useProductConfig() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const refresh = useCallback(() => {
return productConfigApi.get().then(setConfig)
}, [])
useEffect(() => {
refresh()
}, [refresh])
return { config, refresh }
}
+10
View File
@@ -49,6 +49,16 @@ export const categoriesApi = {
if (!name) return Promise.reject(new Error("Category name is required.")) if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockCategories.find((c) => c.categoryId === categoryId) const category = mockCategories.find((c) => c.categoryId === categoryId)
if (!category) return Promise.reject(new Error("Category not found.")) if (!category) return Promise.reject(new Error("Category not found."))
if (request.parentId !== undefined) {
const parentId = request.parentId
if (parentId === categoryId) {
return Promise.reject(new Error("A category cannot be its own parent."))
}
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
return Promise.reject(new Error("Selected parent category does not exist."))
}
category.parentId = parentId
}
category.name = name category.name = name
return mockDelay(category) return mockDelay(category)
}, },
+21
View File
@@ -4,6 +4,7 @@
import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data" import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data"
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement" import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
import { Grn } from "@/types/grn" import { Grn } from "@/types/grn"
import { ProductConfig } from "@/types/settings"
import { import {
AdjustmentStatus, AdjustmentStatus,
CountStatus, CountStatus,
@@ -14,6 +15,26 @@ import {
TransferStatus, TransferStatus,
} from "@/types/stock" } from "@/types/stock"
// Company-level Product Configuration (Settings → Product Configuration). Off by
// default except the tracking-related toggles, matching the shipped design.
export const mockProductConfig: ProductConfig = {
subcategories: false,
brands: false,
productImages: false,
serialNumbers: true,
batchLotTracking: true,
expiryMfgDates: true,
warranty: true,
serviceItems: true,
adHocSaleLines: true,
minPriceFloor: false,
maxPriceCeiling: false,
freePricingProducts: false,
fractionalQuantities: false,
packConversion: true,
allowNegativeStock: false,
}
export const mockWarehouses: Warehouse[] = [ export const mockWarehouses: Warehouse[] = [
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" }, { warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" }, { warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
@@ -0,0 +1,16 @@
// Product Configuration client, mirroring lib/api/brands.ts. A single company-level
// settings object rather than a list — in-memory sample data (lib/api/mock-data.ts),
// no backend API calls.
import { ProductConfig, UpdateProductConfigRequest } from "@/types/settings"
import { mockDelay, mockProductConfig } from "@/lib/api/mock-data"
export const productConfigApi = {
get(): Promise<ProductConfig> {
return mockDelay({ ...mockProductConfig })
},
update(request: UpdateProductConfigRequest): Promise<ProductConfig> {
Object.assign(mockProductConfig, request)
return mockDelay({ ...mockProductConfig })
},
}
+1
View File
@@ -144,6 +144,7 @@ export interface CreateCategoryRequest {
export interface UpdateCategoryRequest { export interface UpdateCategoryRequest {
name: string name: string
parentId?: number | null
} }
export interface Brand { export interface Brand {
+25
View File
@@ -0,0 +1,25 @@
// Company-level Product Configuration — capability toggles that gate optional
// product/category behaviour across the app (frontend-only, no backend yet).
export interface ProductConfig {
// Product Capabilities
subcategories: boolean
brands: boolean
productImages: boolean
serialNumbers: boolean
batchLotTracking: boolean
expiryMfgDates: boolean
warranty: boolean
serviceItems: boolean
adHocSaleLines: boolean
// Pricing & Quantity
minPriceFloor: boolean
maxPriceCeiling: boolean
freePricingProducts: boolean
fractionalQuantities: boolean
packConversion: boolean
// Stock Behaviour
allowNegativeStock: boolean
}
export type UpdateProductConfigRequest = Partial<ProductConfig>