From 47683ddd0f7a24a4740cb81845fc595b8d3214ee Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Fri, 17 Jul 2026 11:34:46 +0530 Subject: [PATCH] toggle screen for product configurations --- .../app/dashboard/products/[id]/page.tsx | 73 ++++++- .../dashboard/products/categories/page.tsx | 56 ++++- .../dashboard/products/configuration/page.tsx | 205 ++++++++++++++++++ .../app/dashboard/products/new/page.tsx | 40 ++-- .../components/Layouts/AppSidebar.tsx | 2 + Frontend/erp-system/components/ui/switch.tsx | 25 +++ .../erp-system/hooks/use-product-config.ts | 21 ++ Frontend/erp-system/lib/api/categories.ts | 10 + Frontend/erp-system/lib/api/mock-data.ts | 21 ++ Frontend/erp-system/lib/api/product-config.ts | 16 ++ Frontend/erp-system/types/master-data.ts | 1 + Frontend/erp-system/types/settings.ts | 25 +++ 12 files changed, 468 insertions(+), 27 deletions(-) create mode 100644 Frontend/erp-system/app/dashboard/products/configuration/page.tsx create mode 100644 Frontend/erp-system/components/ui/switch.tsx create mode 100644 Frontend/erp-system/hooks/use-product-config.ts create mode 100644 Frontend/erp-system/lib/api/product-config.ts create mode 100644 Frontend/erp-system/types/settings.ts diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 94cd29d..bbde2fc 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" 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 { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" 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 { Button, buttonVariants } from "@/components/ui/button" @@ -49,10 +50,12 @@ export default function ItemDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() const itemId = Number(params.id) + const { config } = useProductConfig() + const subcategoriesEnabled = !!config?.subcategories const [item, setItem] = useState(null) const [etag, setEtag] = useState(null) - const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) + const [categories, setCategories] = useState([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [vendors, setVendors] = useState<{ vendorId: 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 [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) + const [subCategoryId, setSubCategoryId] = useState(null) const [baseUomId, setBaseUomId] = useState(null) const [defaultVendorId, setDefaultVendorId] = useState(null) const [itemType, setItemType] = useState("Stocked") @@ -117,7 +121,7 @@ export default function ItemDetailPage() { useEffect(() => { if (!Number.isFinite(itemId)) return 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]) => { setCategories(cat.items) setUoms(uo.items) @@ -128,10 +132,42 @@ export default function ItemDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [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() { if (!item || !etag) return setSaveError(null) - const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + const nextErrors = validateItemForm({ sku, name, categoryId: effectiveCategoryId, baseUomId }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return @@ -139,7 +175,7 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( 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 ) applyItem(result.data) @@ -339,12 +375,12 @@ export default function ItemDetailPage() {
- value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + value={categoryId} onValueChange={handleCategoryChange} disabled={conflict}> - {categories.map((c) => ( + {topCategories.map((c) => ( {c.name} @@ -353,6 +389,27 @@ export default function ItemDetailPage() {
+ {subcategoriesEnabled && ( +
+ + + value={subCategoryId} + onValueChange={setSubCategoryId} + disabled={conflict || subCategoryOptions.length === 0} + > + + + + + {subCategoryOptions.map((c) => ( + + {c.name} + + ))} + + +
+ )}
value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 18909ec..e676b96 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -8,6 +8,7 @@ import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" import { validateCategoryName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" +import { useProductConfig } from "@/hooks/use-product-config" import { PaginationMeta } from "@/types/common" import { Category } from "@/types/master-data" @@ -26,10 +27,17 @@ type SortOrder = "asc" | "desc" const PAGE_SIZE = 5 export default function CategoriesPage() { + const { config } = useProductConfig() + const subcategoriesEnabled = !!config?.subcategories + const [categories, setCategories] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(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([]) + const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") const [sortOrder, setSortOrder] = useState("asc") @@ -38,6 +46,7 @@ export default function CategoriesPage() { const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [name, setName] = useState("") + const [parentId, setParentId] = useState(null) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) const [deletingId, setDeletingId] = useState(null) @@ -62,13 +71,28 @@ export default function CategoriesPage() { .catch((err) => setError(errorMessage(err))) } + function loadAllCategories() { + categoriesApi + .list({ pageSize: 500 }) + .then((res) => setAllCategories(res.items)) + .catch(() => {}) + } + useEffect(load, [search, sortOrder, page]) + useEffect(loadAllCategories, []) 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() { setEditing(null) setName("") + setParentId(null) setErrors({}) setOpen(true) } @@ -76,6 +100,7 @@ export default function CategoriesPage() { function openEditDialog(category: Category) { setEditing(category) setName(category.name) + setParentId(category.parentId) setErrors({}) setOpen(true) } @@ -88,14 +113,16 @@ export default function CategoriesPage() { setSubmitting(true) try { const category = editing - ? await categoriesApi.update(editing.categoryId, { name }) - : await categoriesApi.create({ name }) + ? await categoriesApi.update(editing.categoryId, { name, parentId: subcategoriesEnabled ? parentId : null }) + : await categoriesApi.create({ name, parentId: subcategoriesEnabled ? parentId : null }) toast.success(editing ? "Category updated" : "Category created", category.name) setOpen(false) setName("") + setParentId(null) setEditing(null) setErrors({}) load() + loadAllCategories() } catch (err) { setErrors({ name: errorMessage(err) }) toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) @@ -135,7 +162,9 @@ export default function CategoriesPage() { {editing ? "Edit category" : "New category"} - Give the category a name. + + {subcategoriesEnabled ? "Give the category a name and, optionally, a parent." : "Give the category a name."} + @@ -143,6 +172,23 @@ export default function CategoriesPage() { setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + {subcategoriesEnabled && ( + + Parent category (optional) + value={parentId} onValueChange={setParentId}> + + + + + {parentOptions.map((c) => ( + + {c.name} + + ))} + + + + )}
+
+ + {error && ( +
{error}
+ )} + + {!error && !config && ( +
+ + + +
+ )} + + {config && ( +
+ {SECTIONS.map((section) => ( +
+
+ +

{section.title}

+
+
+ {section.fields.map((field) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index a5666c7..0c1484f 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -12,6 +12,7 @@ import { variantCategoriesApi } from "@/lib/api/variants" import { errorMessage } from "@/lib/error-map" import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" +import { useProductConfig } from "@/hooks/use-product-config" import { Category, VariantCategory } from "@/types/master-data" import { Badge } from "@/components/ui/badge" @@ -57,6 +58,8 @@ const DEFAULT_BASE_UOM_ID = 1 export default function NewItemPage() { const router = useRouter() + const { config } = useProductConfig() + const subcategoriesEnabled = !!config?.subcategories const [categories, setCategories] = useState(null) const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) @@ -92,7 +95,10 @@ export default function NewItemPage() { .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( () => (categories ?? []).filter((c) => c.parentId === categoryId), [categories, categoryId] @@ -259,21 +265,23 @@ export default function NewItemPage() { -
- - value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> - - - - - {subCategoryOptions.map((c) => ( - - {c.name} - - ))} - - -
+ {subcategoriesEnabled && ( +
+ + value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> + + + + + {subCategoryOptions.map((c) => ( + + {c.name} + + ))} + + +
+ )}
value={brandId} onValueChange={setBrandId}> diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 8bddb2a..5a4e97e 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -15,6 +15,7 @@ import { Package, PackageCheck, Settings, + SlidersHorizontal, ShoppingCart, SwatchBook, Tag, @@ -44,6 +45,7 @@ const navItems: { { title: "Category", href: "/dashboard/products/categories", icon: ListTree }, { title: "Brand", href: "/dashboard/products/brands", icon: Tag }, { 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 }, diff --git a/Frontend/erp-system/components/ui/switch.tsx b/Frontend/erp-system/components/ui/switch.tsx new file mode 100644 index 0000000..3df6fb0 --- /dev/null +++ b/Frontend/erp-system/components/ui/switch.tsx @@ -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 ( + + + + ) +} + +export { Switch } diff --git a/Frontend/erp-system/hooks/use-product-config.ts b/Frontend/erp-system/hooks/use-product-config.ts new file mode 100644 index 0000000..8548721 --- /dev/null +++ b/Frontend/erp-system/hooks/use-product-config.ts @@ -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(null) + + const refresh = useCallback(() => { + return productConfigApi.get().then(setConfig) + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + return { config, refresh } +} diff --git a/Frontend/erp-system/lib/api/categories.ts b/Frontend/erp-system/lib/api/categories.ts index 23b211c..33224c8 100644 --- a/Frontend/erp-system/lib/api/categories.ts +++ b/Frontend/erp-system/lib/api/categories.ts @@ -49,6 +49,16 @@ export const categoriesApi = { if (!name) return Promise.reject(new Error("Category name is required.")) const category = mockCategories.find((c) => c.categoryId === categoryId) 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 return mockDelay(category) }, diff --git a/Frontend/erp-system/lib/api/mock-data.ts b/Frontend/erp-system/lib/api/mock-data.ts index 32f9e97..4883524 100644 --- a/Frontend/erp-system/lib/api/mock-data.ts +++ b/Frontend/erp-system/lib/api/mock-data.ts @@ -4,6 +4,7 @@ import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data" import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement" import { Grn } from "@/types/grn" +import { ProductConfig } from "@/types/settings" import { AdjustmentStatus, CountStatus, @@ -14,6 +15,26 @@ import { TransferStatus, } 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[] = [ { warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" }, { warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" }, diff --git a/Frontend/erp-system/lib/api/product-config.ts b/Frontend/erp-system/lib/api/product-config.ts new file mode 100644 index 0000000..8038861 --- /dev/null +++ b/Frontend/erp-system/lib/api/product-config.ts @@ -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 { + return mockDelay({ ...mockProductConfig }) + }, + + update(request: UpdateProductConfigRequest): Promise { + Object.assign(mockProductConfig, request) + return mockDelay({ ...mockProductConfig }) + }, +} diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 7a16bd3..9656928 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -144,6 +144,7 @@ export interface CreateCategoryRequest { export interface UpdateCategoryRequest { name: string + parentId?: number | null } export interface Brand { diff --git a/Frontend/erp-system/types/settings.ts b/Frontend/erp-system/types/settings.ts new file mode 100644 index 0000000..56528f2 --- /dev/null +++ b/Frontend/erp-system/types/settings.ts @@ -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 -- 2.52.0