From 295ec5799f3d7dfbf03a63f606e4f27c7643de22 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Mon, 20 Jul 2026 17:11:13 +0530 Subject: [PATCH] feat: enhance product management features - Implement sorting and filtering for categories in the dashboard. - Add status filtering and sorting capabilities for categories. - Introduce warehouse selection and base UOM configuration in the new item creation page. - Fetch and display brands and subcategories in the items page. - Update product settings to manage subcategories and brands. - Refactor sidebar navigation to include configuration options. --- .../app/dashboard/products/[id]/page.tsx | 348 ++-------------- .../app/dashboard/products/brands/page.tsx | 102 ++++- .../dashboard/products/categories/page.tsx | 102 ++++- .../app/dashboard/products/new/page.tsx | 374 ++++++++---------- .../app/dashboard/products/page.tsx | 46 ++- .../app/dashboard/products/settings/page.tsx | 77 ++-- .../components/Layouts/AppSidebar.tsx | 4 +- 7 files changed, 449 insertions(+), 604 deletions(-) diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index bc2eeff..63ff5ab 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -3,17 +3,16 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" import Link from "next/link" -import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" +import { AlertTriangle, ArrowLeft, Save } from "lucide-react" import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" import { uomsApi } from "@/lib/api/uoms" -import { vendorsApi } from "@/lib/api/vendors" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" +import { validateItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data" +import { Item, StockNature, TrackingMode } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -21,30 +20,9 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" -interface ReorderDraft { - key: string - warehouseId: number | null - reorderPoint: string - reorderQty: string -} - -interface ConversionDraft { - key: string - fromUom: number | null - toUom: number | null - factor: string -} - -let keySeq = 0 -function newKey() { - keySeq += 1 - return `row-${keySeq}` -} - export default function ItemDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() @@ -54,13 +32,13 @@ export default function ItemDetailPage() { const [etag, setEtag] = useState(null) const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) 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 }[]>([]) const [loadError, setLoadError] = useState(null) // Basic info form const [sku, setSku] = useState("") const [name, setName] = useState("") + // No longer editable here — carried through unchanged so a save doesn't silently clear it. const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) // Carried through edits so a save doesn't silently drop the item's subcategory/brand. @@ -68,10 +46,15 @@ export default function ItemDetailPage() { const [subCategoryId, setSubCategoryId] = useState(null) const [brandId, setBrandId] = useState(null) const [baseUomId, setBaseUomId] = useState(null) - const [defaultVendorId, setDefaultVendorId] = useState(null) const [stockNature, setStockNature] = useState("Stocked") + // Default vendor, tax class, and tracking mode are no longer editable on this page — + // carried through unchanged (from the loaded item) so a save doesn't silently clear them. + const [defaultVendorId, setDefaultVendorId] = useState(null) const [trackingMode, setTrackingMode] = useState("None") const [taxClass, setTaxClass] = useState("") + // Frontend-only: there's no warehouse field anywhere on the Item contract, so this + // isn't sent on save — nothing to wire it to server-side. + const [warehouseId, setWarehouseId] = useState(null) const [errors, setErrors] = useState>({}) const [conflict, setConflict] = useState(false) @@ -79,18 +62,6 @@ export default function ItemDetailPage() { const [saving, setSaving] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false) - // Reorder settings - const [reorderLines, setReorderLines] = useState([]) - const [reorderErrors, setReorderErrors] = useState>>({}) - const [reorderSaveError, setReorderSaveError] = useState(null) - const [savingReorder, setSavingReorder] = useState(false) - - // UOM conversions - const [conversionLines, setConversionLines] = useState([]) - const [conversionErrors, setConversionErrors] = useState>>({}) - const [conversionSaveError, setConversionSaveError] = useState(null) - const [savingConversions, setSavingConversions] = useState(false) - function applyItem(data: Item) { setItem(data) setSku(data.sku) @@ -104,8 +75,6 @@ export default function ItemDetailPage() { setStockNature(data.stockNature) setTrackingMode(data.trackingMode) setTaxClass(data.taxClass ?? "") - setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) - setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) } function load() { @@ -123,11 +92,10 @@ export default function ItemDetailPage() { useEffect(() => { if (!Number.isFinite(itemId)) return load() - Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) - .then(([cat, uo, ve, wh]) => { + Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })]) + .then(([cat, uo, wh]) => { setCategories(cat.items) setUoms(uo.items) - setVendors(ve.items) setWarehouses(wh.items) }) .catch(() => {}) @@ -183,83 +151,6 @@ export default function ItemDetailPage() { } } - function updateReorderLine(key: string, patch: Partial) { - setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) - } - function removeReorderLine(key: string) { - setReorderLines((prev) => prev.filter((l) => l.key !== key)) - } - - async function handleSaveReorder() { - if (!item) return - setReorderSaveError(null) - const nextErrors: Record> = {} - for (const line of reorderLines) { - const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty }) - if (Object.keys(errs).length > 0) nextErrors[line.key] = errs - } - setReorderErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - setReorderSaveError("Fix the highlighted rows before saving.") - return - } - - const settings: ItemReorderSetting[] = reorderLines.map((l) => ({ - warehouseId: l.warehouseId as number, - reorderPoint: Number(l.reorderPoint), - reorderQty: Number(l.reorderQty), - })) - - setSavingReorder(true) - try { - const result = await itemsApi.updateReorder(item.itemId, { settings }) - setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev)) - toast.success("Reorder settings saved") - } catch (err) { - setReorderSaveError(errorMessage(err)) - toast.error("Could not save reorder settings", errorMessage(err)) - } finally { - setSavingReorder(false) - } - } - - function updateConversionLine(key: string, patch: Partial) { - setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) - } - function removeConversionLine(key: string) { - setConversionLines((prev) => prev.filter((l) => l.key !== key)) - } - - async function handleSaveConversions() { - if (!item) return - setConversionSaveError(null) - const nextErrors: Record> = {} - for (const line of conversionLines) { - const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor }) - if (Object.keys(errs).length > 0) nextErrors[line.key] = errs - } - setConversionErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - setConversionSaveError("Fix the highlighted rows before saving.") - return - } - - const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) })) - - setSavingConversions(true) - try { - const result = await itemsApi.updateUomConversions(item.itemId, { conversions }) - setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev)) - setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) - toast.success("UOM conversions saved") - } catch (err) { - setConversionSaveError(errorMessage(err)) - toast.error("Could not save UOM conversions", errorMessage(err)) - } finally { - setSavingConversions(false) - } - } - function uomName(uomId: number) { return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` } @@ -339,13 +230,14 @@ export default function ItemDetailPage() { setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> -
- - setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} /> -
- value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + + value={categoryId} + onValueChange={setCategoryId} + disabled={conflict} + items={categories.map((c) => ({ label: c.name, value: c.categoryId }))} + > @@ -361,7 +253,12 @@ export default function ItemDetailPage() {
- value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> + + value={baseUomId} + onValueChange={setBaseUomId} + disabled={conflict} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} + > @@ -375,25 +272,6 @@ export default function ItemDetailPage() {
-
- - value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}> - - - - - {vendors.map((v) => ( - - {v.code} — {v.name} - - ))} - - -
-
- - setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} /> -
{/* "Item type" now means a Color/Size dimension master — this field is the stock-nature one it used to be confused with (docs/11 §8). */} @@ -410,15 +288,22 @@ export default function ItemDetailPage() {
- - value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> + + + value={warehouseId} + onValueChange={setWarehouseId} + disabled={conflict} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > - + - None - Batch - Serial + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))}
@@ -434,165 +319,8 @@ export default function ItemDetailPage() { -
-
-
-

Reorder settings

-

Per-warehouse reorder point and quantity (FR-MD-05).

-
- -
- - {reorderLines.length > 0 && ( - - - - Warehouse - Reorder point - Reorder qty - - - - - {reorderLines.map((line) => { - const errs = reorderErrors[line.key] ?? {} - return ( - - - value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}> - - - - - {warehouses.map((w) => ( - - {w.code} - - ))} - - - - - - updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" /> - - - - updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" /> - - - - - - - ) - })} - -
- )} - - {reorderSaveError && ( -
{reorderSaveError}
- )} - -
- -
-
- -
-
-
-

UOM conversions

-

Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).

-
- -
- - {conversionLines.length > 0 && ( - - - - From UOM - To UOM - Factor - - - - - {conversionLines.map((line) => { - const errs = conversionErrors[line.key] ?? {} - return ( - - - value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - - - - - value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}> - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - - - - - - updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" /> - - - - - - - ) - })} - -
- )} - - {conversionSaveError && ( -
{conversionSaveError}
- )} - -
- -
-
-

- {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."} + {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03).

) diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index 0ad8c32..4b83c0f 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" +import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" import { validateBrandName } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { PaginationMeta } from "@/types/common" +import { EntityStatus, PaginationMeta } from "@/types/common" import { Brand } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" @@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" type SortOrder = "asc" | "desc" +type SortKey = "brandId" | "name" | "status" | "createdAt" +type StatusFilter = EntityStatus | "All" const PAGE_SIZE = 5 @@ -33,8 +35,12 @@ export default function BrandsPage() { const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") - const [sortOrder, setSortOrder] = useState("asc") + const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + // Sorts only the currently loaded page client-side — the backend ignores `sort` and + // always returns Name ascending, so this doesn't hold across page turns or other columns. + const [sortKey, setSortKey] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) @@ -50,12 +56,12 @@ export default function BrandsPage() { useEffect(() => { setPage(1) - }, [search, sortOrder]) + }, [search, status]) function load() { setError(null) brandsApi - .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE }) .then((res) => { setBrands(res.items) setPagination(res.pagination) @@ -63,9 +69,30 @@ export default function BrandsPage() { .catch((err) => setError(errorMessage(err))) } - useEffect(load, [search, sortOrder, page]) + useEffect(load, [search, status, page]) - const hasFilters = search.trim().length > 0 + const hasFilters = search.trim().length > 0 || status !== "All" + + const sortedBrands = brands + ? [...brands].sort((a, b) => { + const cmp = + sortKey === "brandId" + ? a.brandId - b.brandId + : sortKey === "createdAt" + ? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + : a[sortKey].localeCompare(b[sortKey]) + return sortOrder === "asc" ? cmp : -cmp + }) + : null + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortOrder((o) => (o === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortOrder("asc") + } + } function openCreateDialog() { setEditing(null) @@ -176,13 +203,14 @@ export default function BrandsPage() { aria-label="Search brands" /> - value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> - + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + - Name (A–Z) - Name (Z–A) + All statuses + Active + Inactive @@ -203,7 +231,7 @@ export default function BrandsPage() {

- {hasFilters ? "No brands match your search." : "No brands yet."} + {hasFilters ? "No brands match your search/filter." : "No brands yet."}

)} @@ -213,15 +241,23 @@ export default function BrandsPage() { - ID - Name - Status - Created At + + toggleSort("brandId")} /> + + + toggleSort("name")} /> + + + toggleSort("status")} /> + + + toggleSort("createdAt")} /> + Actions - {brands.map((b) => ( + {sortedBrands!.map((b) => ( #{b.brandId} {b.name} @@ -313,3 +349,35 @@ export default function BrandsPage() { ) } + +function SortableHeader({ + label, + active, + order, + onClick, +}: { + label: string + active: boolean + order: SortOrder + onClick: () => void +}) { + return ( + + ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 8c5dac3..d11549e 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" +import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" 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 { PaginationMeta } from "@/types/common" +import { EntityStatus, PaginationMeta } from "@/types/common" import { Category } from "@/types/master-data" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" @@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" type SortOrder = "asc" | "desc" +type SortKey = "categoryId" | "name" | "status" | "createdAt" +type StatusFilter = EntityStatus | "All" const PAGE_SIZE = 5 @@ -33,8 +35,12 @@ export default function CategoriesPage() { const [searchInput, setSearchInput] = useState("") const [search, setSearch] = useState("") - const [sortOrder, setSortOrder] = useState("asc") + const [status, setStatus] = useState("All") const [page, setPage] = useState(1) + // Sorts only the currently loaded page client-side — the backend ignores `sort` and + // always returns Name ascending, so this doesn't hold across page turns or other columns. + const [sortKey, setSortKey] = useState("name") + const [sortOrder, setSortOrder] = useState("asc") const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) @@ -50,12 +56,12 @@ export default function CategoriesPage() { useEffect(() => { setPage(1) - }, [search, sortOrder]) + }, [search, status]) function load() { setError(null) categoriesApi - .list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) + .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE }) .then((res) => { setCategories(res.items) setPagination(res.pagination) @@ -63,9 +69,30 @@ export default function CategoriesPage() { .catch((err) => setError(errorMessage(err))) } - useEffect(load, [search, sortOrder, page]) + useEffect(load, [search, status, page]) - const hasFilters = search.trim().length > 0 + const hasFilters = search.trim().length > 0 || status !== "All" + + const sortedCategories = categories + ? [...categories].sort((a, b) => { + const cmp = + sortKey === "categoryId" + ? a.categoryId - b.categoryId + : sortKey === "createdAt" + ? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + : a[sortKey].localeCompare(b[sortKey]) + return sortOrder === "asc" ? cmp : -cmp + }) + : null + + function toggleSort(key: SortKey) { + if (key === sortKey) { + setSortOrder((o) => (o === "asc" ? "desc" : "asc")) + } else { + setSortKey(key) + setSortOrder("asc") + } + } function openCreateDialog() { setEditing(null) @@ -175,13 +202,14 @@ export default function CategoriesPage() { aria-label="Search categories" /> - value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> - + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + - Name (A–Z) - Name (Z–A) + All statuses + Active + Inactive @@ -202,7 +230,7 @@ export default function CategoriesPage() {

- {hasFilters ? "No categories match your search." : "No categories yet."} + {hasFilters ? "No categories match your search/filter." : "No categories yet."}

)} @@ -212,15 +240,23 @@ export default function CategoriesPage() {
- ID - Name - Status - Created At + + toggleSort("categoryId")} /> + + + toggleSort("name")} /> + + + toggleSort("status")} /> + + + toggleSort("createdAt")} /> + Actions - {categories.map((c) => ( + {sortedCategories!.map((c) => ( #{c.categoryId} {c.name} @@ -320,3 +356,35 @@ export default function CategoriesPage() { ) } + +function SortableHeader({ + label, + active, + order, + onClick, +}: { + label: string + active: boolean + order: SortOrder + onClick: () => void +}) { + return ( + + ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index bc9acc6..d3b99e5 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -11,10 +11,11 @@ import { brandsApi } from "@/lib/api/brands" import { itemTypesApi } from "@/lib/api/item-types" import { productConfig } from "@/lib/api/product-config" import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data" +import { validateVariantItemForm } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" -import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data" +import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -36,26 +37,11 @@ function buildVariantSku(categoryLabel: string, values: string[]): string { return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") } -/** - * Colour is special-cased by name. This stays a frontend concern: item types are names - * only — there is no value table server-side to hang a hex column off (docs/10 Part C.9). - */ -function isColorCategory(categoryName: string): boolean { - return categoryName.trim().toLowerCase() === "color" -} - -function encodeColorValue(name: string, hex: string): string { - return `${name}|${hex}` -} - -function decodeColorValue(value: string): { name: string; hex: string } { - const separatorIndex = value.indexOf("|") - if (separatorIndex === -1) return { name: value, hex: "#d4d4d8" } - return { name: value.slice(0, separatorIndex), hex: value.slice(separatorIndex + 1) } -} - -function partLabel(part: { name: string; value: string }): string { - return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value +/** The item builder only ever offers these two dimensions, regardless of what else exists + * in the Item Types master list. */ +const BUILDER_ITEM_TYPES = ["color", "size"] +function isBuilderItemType(name: string): boolean { + return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase()) } export default function NewItemPage() { @@ -65,29 +51,27 @@ export default function NewItemPage() { const [brands, setBrands] = useState(null) const [itemTypes, setItemTypes] = useState(null) const [config, setConfig] = useState(null) - /** - * This form has no Base UOM field by design, so it adopts the first UOM as the base. - * It used to hardcode `uomId: 1`, which only worked because the mock seeded that id — - * against a real database that is a 422 waiting to happen, or worse, silently the wrong - * unit. Null here means "no UOM exists yet" and the form says so rather than guessing. - */ + const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) + /** Defaults to the first UOM once loaded; null only means none exist yet. */ const [baseUomId, setBaseUomId] = useState(null) + const [stockNature, setStockNature] = useState("Stocked") const [loadError, setLoadError] = useState(null) const [categoryId, setCategoryId] = useState(null) const [subCategories, setSubCategories] = useState([]) const [subCategoryId, setSubCategoryId] = useState(null) const [brandId, setBrandId] = useState(null) + // Frontend-only: there's no warehouse field anywhere on the Item contract, so this + // isn't sent on submit — nothing to wire it to server-side. + const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) + const [warehouseId, setWarehouseId] = useState(null) const [checkedItemTypeIds, setCheckedItemTypeIds] = useState([]) const [valuesByCategory, setValuesByCategory] = useState>({}) const [inputByCategory, setInputByCategory] = useState>({}) - const [colorNameByCategory, setColorNameByCategory] = useState>({}) - - const [addingCategory, setAddingCategory] = useState(false) - const [newCategoryName, setNewCategoryName] = useState("") - const [newCategoryError, setNewCategoryError] = useState(null) - const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false) + // Lets a specific generated combination be dropped from the preview table before + // submit, without having to remove and re-add the whole value that produced it. + const [removedVariantKeys, setRemovedVariantKeys] = useState>(new Set()) const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) @@ -99,14 +83,17 @@ export default function NewItemPage() { brandsApi.list({ pageSize: 200, status: "Active" }), itemTypesApi.list({ pageSize: 200, status: "Active" }), productConfig(), - uomsApi.list({ pageSize: 1 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), ]) - .then(([cat, br, types, cfg, uoms]) => { + .then(([cat, br, types, cfg, uoms, wh]) => { setCategories(cat.items) setBrands(br.items) setItemTypes(types.items) setConfig(cfg) + setUoms(uoms.items) setBaseUomId(uoms.items[0]?.uomId ?? null) + setWarehouses(wh.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) @@ -141,30 +128,8 @@ export default function NewItemPage() { ) } - async function handleAddItemType() { - const nextErrors = validateItemTypeName(newCategoryName) - if (nextErrors.name) { - setNewCategoryError(nextErrors.name) - return - } - setAddingCategorySubmitting(true) - try { - const created = await itemTypesApi.create({ name: newCategoryName }) - setItemTypes((prev) => [...(prev ?? []), created.data]) - setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId]) - setNewCategoryName("") - setNewCategoryError(null) - setAddingCategory(false) - toast.success("Item type created", created.data.name) - } catch (err) { - setNewCategoryError(errorMessage(err)) - } finally { - setAddingCategorySubmitting(false) - } - } - - function addValue(itemTypeId: number, overrideValue?: string) { - const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim() + function addValue(itemTypeId: number) { + const value = (inputByCategory[itemTypeId] ?? "").trim() if (value) { setValuesByCategory((prev) => { const existing = prev[itemTypeId] ?? [] @@ -191,7 +156,7 @@ export default function NewItemPage() { [itemTypes, checkedItemTypeIds, valuesByCategory] ) - const variants = useMemo(() => { + const allVariants = useMemo(() => { if (activeCategories.length === 0) return [] let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }] for (const cat of activeCategories) { @@ -208,10 +173,19 @@ export default function NewItemPage() { } return combinations.map((c) => ({ ...c, - sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)), + sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)), })) }, [activeCategories, effectiveLabel]) + const variants = useMemo( + () => allVariants.filter((v) => !removedVariantKeys.has(v.key)), + [allVariants, removedVariantKeys] + ) + + function removeVariant(key: string) { + setRemovedVariantKeys((prev) => new Set(prev).add(key)) + } + async function handleSubmit() { setSubmitError(null) const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) @@ -228,7 +202,7 @@ export default function NewItemPage() { for (const variant of variants) { await itemsApi.create({ sku: variant.sku, - name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`, // Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the // category, which lost the parent entirely. The server rejects a mismatched // pair with 422. @@ -236,7 +210,7 @@ export default function NewItemPage() { subCategoryId, brandId, baseUomId, - stockNature: "Stocked", + stockNature, trackingMode: "None", }) created += 1 @@ -294,7 +268,11 @@ export default function NewItemPage() {
- value={categoryId} onValueChange={handleCategoryChange}> + + value={categoryId} + onValueChange={handleCategoryChange} + items={(categories ?? []).map((c) => ({ label: c.name, value: c.categoryId }))} + > @@ -313,7 +291,12 @@ export default function NewItemPage() { {config?.subcategoriesEnabled && (
- value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}> + + value={subCategoryId} + onValueChange={setSubCategoryId} + disabled={subCategories.length === 0} + items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))} + > @@ -330,7 +313,11 @@ export default function NewItemPage() { {config?.brandsEnabled && (
- value={brandId} onValueChange={setBrandId}> + + value={brandId} + onValueChange={setBrandId} + items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + > @@ -344,6 +331,57 @@ export default function NewItemPage() {
)} +
+ + + value={warehouseId} + onValueChange={setWarehouseId} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > + + + + + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))} + + +
+
+ + + value={baseUomId} + onValueChange={setBaseUomId} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} + > + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + +
+
+ + value={stockNature} onValueChange={(v) => v && setStockNature(v)}> + + + + + Stocked + Non-stocked + Service + + +
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no @@ -358,67 +396,19 @@ export default function NewItemPage() {
- {(itemTypes ?? []).map((t) => ( - - ))} - {!addingCategory && ( - - )} + {(itemTypes ?? []) + .filter((t) => isBuilderItemType(t.name)) + .map((t) => ( + + ))}
- {addingCategory && ( -
-
- setNewCategoryName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddItemType() - } - }} - placeholder="Material" - className="h-11 max-w-xs text-base" - aria-invalid={!!newCategoryError} - autoFocus - /> - - -
- -
- )} - {checkedItemTypeIds.length > 0 && ( @@ -426,86 +416,43 @@ export default function NewItemPage() { {(itemTypes ?? []) .filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) .map((t) => { - const isColor = isColorCategory(t.name) const currentInput = inputByCategory[t.itemTypeId] ?? "" - const currentColorName = colorNameByCategory[t.itemTypeId] ?? "" - - function addColor() { - const name = currentColorName.trim() - if (!name) return - addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444")) - setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" })) - } return (
- {isColor ? ( - <> - setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} - className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5" - aria-label="Pick color" - /> - setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - addColor() - } - }} - placeholder="Color name (e.g. Red)" - className="h-11 text-base" - /> - - ) : ( - setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - addValue(t.itemTypeId) - } - }} - placeholder={t.name} - className="h-11 text-base" - /> - )} -
- {(valuesByCategory[t.itemTypeId] ?? []).map((v) => { - const decoded = isColor ? decodeColorValue(v) : null - return ( - - {decoded && ( - - ) - })} + {(valuesByCategory[t.itemTypeId] ?? []).map((v) => ( + + {v} + + + ))}
) @@ -526,29 +473,30 @@ export default function NewItemPage() { Item contract and no initial-receipt flow — stock arrives via a GRN. The input was informational-only under the mock and would now be a field that silently discards what you type. */} + {variants.map((variant) => ( - {variant.parts.map((part, i) => { - const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null - return ( - - - {decoded && ( - - - ) - })} - {variant.sku} + {variant.parts.map((part, i) => ( + + {part.value} + + ))} + {variant.sku} + + + ))} diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index e77364f..e5b4e9e 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -6,10 +6,12 @@ import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Sear import { itemsApi } from "@/lib/api/items" import { categoriesApi } from "@/lib/api/categories" +import { brandsApi } from "@/lib/api/brands" +import { productConfig } from "@/lib/api/product-config" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { EntityStatus, PaginationMeta } from "@/types/common" -import { Category, ItemListItem, TrackingMode } from "@/types/master-data" +import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" @@ -26,6 +28,9 @@ const PAGE_SIZE = 10 export default function ItemsPage() { const [items, setItems] = useState(null) const [categories, setCategories] = useState([]) + const [subCategories, setSubCategories] = useState([]) + const [brands, setBrands] = useState([]) + const [config, setConfig] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) @@ -64,12 +69,33 @@ export default function ItemsPage() { useEffect(load, [page, query, status, categoryId, trackingMode]) useEffect(() => { categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) + brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {}) + productConfig().then(setConfig).catch(() => {}) }, []) + // No "list all subcategories" endpoint exists — they're scoped per category — so once + // categories are in, fetch each one's subcategories in parallel to build a flat lookup. + useEffect(() => { + if (categories.length === 0) return + Promise.all( + categories.map((c) => + categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] })) + ) + ).then((results) => setSubCategories(results.flatMap((r) => r.items))) + }, [categories]) + function categoryName(id: number) { return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` } + function subCategoryName(id: number) { + return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}` + } + + function brandName(id: number) { + return brands.find((b) => b.brandId === id)?.name ?? `#${id}` + } + const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" return ( @@ -106,7 +132,11 @@ export default function ItemsPage() { aria-label="Search items" />
- value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> + + value={categoryId} + onValueChange={(v) => setCategoryId(v ?? "All")} + items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]} + > @@ -175,6 +205,8 @@ export default function ItemsPage() { SKU Name Category + {config?.subcategoriesEnabled && Subcategory} + {config?.brandsEnabled && Brand} Type Tracking Status @@ -191,6 +223,16 @@ export default function ItemsPage() { {item.name} {categoryName(item.categoryId)} + {config?.subcategoriesEnabled && ( + + {item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"} + + )} + {config?.brandsEnabled && ( + + {item.brandId !== null ? brandName(item.brandId) : "—"} + + )} {item.stockNature} {item.trackingMode} diff --git a/Frontend/erp-system/app/dashboard/products/settings/page.tsx b/Frontend/erp-system/app/dashboard/products/settings/page.tsx index ad7c55e..ba369e9 100644 --- a/Frontend/erp-system/app/dashboard/products/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/settings/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Info } from "lucide-react" +import { ArrowLeft, Package } from "lucide-react" import { productConfigApi } from "@/lib/api/product-config" import { errorMessage } from "@/lib/error-map" @@ -17,11 +17,10 @@ import { toast } from "@/components/ui/toast" /** * Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate. * - * Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the - * server (an item write carrying a gated field gets 422 CONFIG_DISABLED); - * `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend - * hiding the builder's type section IS the enforcement. That distinction is surfaced in - * the UI rather than hidden, because it changes what "off" actually guarantees. + * `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are + * enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED). + * `itemTypesEnabled` has no control on this screen but is still part of the record, so + * every save round-trips its current value unchanged (the server rejects a partial body). */ export default function ProductSettingsPage() { const [config, setConfig] = useState(null) @@ -42,7 +41,7 @@ export default function ProductSettingsPage() { useEffect(load, []) - async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) { + async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) { if (!config) return setSaving(flag) try { @@ -90,32 +89,29 @@ export default function ProductSettingsPage() { {!error && config && (
-

Product Capabilities

+
+ +

Product Capabilities

+
+
- toggle("subcategoriesEnabled", v)} - /> +
+ toggle("subcategoriesEnabled", v)} + /> - toggle("brandsEnabled", v)} - /> - - toggle("itemTypesEnabled", v)} - note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything." - /> + toggle("brandsEnabled", v)} + /> +
{config.updatedAt && (

@@ -132,7 +128,6 @@ export default function ProductSettingsPage() { const LABELS: Record = { subcategoriesEnabled: "Subcategories", brandsEnabled: "Brands", - itemTypesEnabled: "Item types", } function ToggleRow({ @@ -141,28 +136,26 @@ function ToggleRow({ checked, busy, onChange, - note, }: { label: string description: string checked: boolean busy: boolean onChange: (next: boolean) => void - note?: string }) { return ( -

+
+
{label} {description} - {note && ( - - - {note} - - )}
-
) } diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index fee71f5..c612b7e 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -19,7 +19,6 @@ import { ShieldCheck, ShoppingCart, SlidersHorizontal, - SwatchBook, Tag, Truck, Users, @@ -53,9 +52,7 @@ const navItems: { { title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes }, { title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree }, { title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag }, - { title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook }, { title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler }, - { title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal }, ], }, { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, @@ -73,6 +70,7 @@ const navItems: { children: [ { title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck }, { title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users }, + { title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal }, ], }, { title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle }, -- 2.52.0