feat: add brands and variant categories management

- Implemented CRUD operations for brands and variant categories in the API.
- Created UI components for managing brands and variant categories, including listing, creating, editing, and deleting.
- Enhanced the sidebar navigation to include links for brands and variant categories.
- Updated the categories API to support pagination and filtering.
- Added validation for brand and variant category names.
- Integrated toast notifications for user feedback on actions.
This commit is contained in:
2026-07-15 18:11:35 +05:30
parent 0e4bcf174b
commit c9a84e235b
14 changed files with 1438 additions and 194 deletions
+64
View File
@@ -0,0 +1,64 @@
// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data"
export interface ListBrandsParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
}
export const brandsApi = {
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockBrands
.filter((b) => !term || b.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
},
create(request: CreateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
if (mockBrands.some((b) => b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
const brand: Brand = { brandId: allocateBrandId(), name, createdAt: new Date().toISOString() }
mockBrands.push(brand)
return mockDelay(brand)
},
update(brandId: number, request: UpdateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
const brand = mockBrands.find((b) => b.brandId === brandId)
if (!brand) return Promise.reject(new Error("Brand not found."))
if (mockBrands.some((b) => b.brandId !== brandId && b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
brand.name = name
return mockDelay(brand)
},
remove(brandId: number): Promise<void> {
const index = mockBrands.findIndex((b) => b.brandId === brandId)
if (index === -1) return Promise.reject(new Error("Brand not found."))
mockBrands.splice(index, 1)
return mockDelay(undefined)
},
}
+38 -20
View File
@@ -1,35 +1,37 @@
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { Category, CategoryTreeNode, CreateCategoryRequest } from "@/types/master-data"
import { Category, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/master-data"
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
function buildTree(categories: Category[]): CategoryTreeNode[] {
const nodes = new Map<number, CategoryTreeNode>(categories.map((c) => [c.categoryId, { ...c, children: [] }]))
const roots: CategoryTreeNode[] = []
for (const node of nodes.values()) {
if (node.parentId !== null && nodes.has(node.parentId)) {
nodes.get(node.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
export interface ListCategoriesParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
}
export const categoriesApi = {
list(): Promise<PagedResponse<Category>> {
const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name))
list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockCategories
.filter((c) => !term || c.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page: 1, pageSize: 200, totalItems: items.length, totalPages: 1 },
pagination: { page, pageSize, totalItems, totalPages },
})
},
tree(): Promise<CategoryTreeNode[]> {
return mockDelay(buildTree(mockCategories))
},
create(request: CreateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
@@ -37,8 +39,24 @@ export const categoriesApi = {
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
return Promise.reject(new Error("Selected parent category does not exist."))
}
const category: Category = { categoryId: allocateCategoryId(), name, parentId }
const category: Category = { categoryId: allocateCategoryId(), name, parentId, createdAt: new Date().toISOString() }
mockCategories.push(category)
return mockDelay(category)
},
update(categoryId: number, request: UpdateCategoryRequest): Promise<Category> {
const name = request.name.trim()
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."))
category.name = name
return mockDelay(category)
},
remove(categoryId: number): Promise<void> {
const index = mockCategories.findIndex((c) => c.categoryId === categoryId)
if (index === -1) return Promise.reject(new Error("Category not found."))
mockCategories.splice(index, 1)
return mockDelay(undefined)
},
}
+4
View File
@@ -36,6 +36,7 @@ function toListItem(item: Item): ItemListItem {
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? null,
baseUomId: item.baseUomId,
defaultVendorId: item.defaultVendorId,
itemType: item.itemType,
@@ -89,6 +90,7 @@ export const itemsApi = {
name: request.name.trim(),
description: request.description?.trim() || null,
categoryId: request.categoryId,
brandId: request.brandId ?? null,
baseUomId: request.baseUomId,
defaultVendorId: request.defaultVendorId ?? null,
itemType: request.itemType,
@@ -97,6 +99,7 @@ export const itemsApi = {
status: "Active",
reorder: [],
conversions: [],
initialQty: request.initialQty ?? null,
createdAt: new Date().toISOString(),
updatedAt: null,
}
@@ -121,6 +124,7 @@ export const itemsApi = {
item.name = request.name.trim()
item.description = request.description?.trim() || null
item.categoryId = request.categoryId
item.brandId = request.brandId ?? null
item.baseUomId = request.baseUomId
item.defaultVendorId = request.defaultVendorId ?? null
item.itemType = request.itemType
+26 -4
View File
@@ -1,7 +1,7 @@
// In-memory sample data backing every lib/api/*.ts module — the app has no
// fetch-based backend connection (lib/api-client.ts and lib/auth-token.ts were
// removed). Shapes mirror docs/11-BACKEND-PHASE1.md.
import { Bin, Category, Item, Uom, Vendor, 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 { Grn } from "@/types/grn"
import {
@@ -51,9 +51,9 @@ export function allocateUomId() {
}
export const mockCategories: Category[] = [
{ categoryId: 3, name: "Hardware", parentId: null },
{ categoryId: 12, name: "Fasteners", parentId: 3 },
{ categoryId: 20, name: "Power Tools", parentId: null },
{ categoryId: 3, name: "Hardware", parentId: null, createdAt: "2026-06-01T08:00:00Z" },
{ categoryId: 12, name: "Fasteners", parentId: 3, createdAt: "2026-06-01T08:05:00Z" },
{ categoryId: 20, name: "Power Tools", parentId: null, createdAt: "2026-06-02T09:00:00Z" },
]
let nextCategoryId = 21
@@ -62,6 +62,28 @@ export function allocateCategoryId() {
return nextCategoryId++
}
export const mockBrands: Brand[] = [
{ brandId: 1, name: "Bosch", createdAt: "2026-06-01T08:00:00Z" },
{ brandId: 2, name: "Makita", createdAt: "2026-06-02T09:00:00Z" },
]
let nextBrandId = 3
export function allocateBrandId() {
return nextBrandId++
}
export const mockVariantCategories: VariantCategory[] = [
{ variantCategoryId: 1, name: "Color", createdAt: "2026-06-01T08:00:00Z" },
{ variantCategoryId: 2, name: "Size", createdAt: "2026-06-01T08:00:00Z" },
]
let nextVariantCategoryId = 3
export function allocateVariantCategoryId() {
return nextVariantCategoryId++
}
export const mockVendors: Vendor[] = [
{
vendorId: 5,
+49
View File
@@ -0,0 +1,49 @@
// One typed client method per Variant Category endpoint, mirroring lib/api/brands.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { CreateVariantCategoryRequest, UpdateVariantCategoryRequest, VariantCategory } from "@/types/master-data"
import { allocateVariantCategoryId, mockVariantCategories, mockDelay } from "@/lib/api/mock-data"
export const variantCategoriesApi = {
list(): Promise<PagedResponse<VariantCategory>> {
const items = [...mockVariantCategories].sort((a, b) => a.name.localeCompare(b.name))
return mockDelay({
items,
pagination: { page: 1, pageSize: items.length || 1, totalItems: items.length, totalPages: 1 },
})
},
create(request: CreateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
if (mockVariantCategories.some((c) => c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
const category: VariantCategory = {
variantCategoryId: allocateVariantCategoryId(),
name,
createdAt: new Date().toISOString(),
}
mockVariantCategories.push(category)
return mockDelay(category)
},
update(variantCategoryId: number, request: UpdateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockVariantCategories.find((c) => c.variantCategoryId === variantCategoryId)
if (!category) return Promise.reject(new Error("Variant category not found."))
if (mockVariantCategories.some((c) => c.variantCategoryId !== variantCategoryId && c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
category.name = name
return mockDelay(category)
},
remove(variantCategoryId: number): Promise<void> {
const index = mockVariantCategories.findIndex((c) => c.variantCategoryId === variantCategoryId)
if (index === -1) return Promise.reject(new Error("Variant category not found."))
mockVariantCategories.splice(index, 1)
return mockDelay(undefined)
},
}
@@ -50,3 +50,25 @@ export function validateCategoryName(name: string): Record<string, string> {
if (!name.trim()) errors.name = "Category name is required"
return errors
}
export function validateBrandName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Brand name is required"
return errors
}
export function validateVariantCategoryName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Category name is required"
return errors
}
export function validateVariantItemForm(input: {
categoryId: number | null
hasVariants: boolean
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.categoryId) errors.categoryId = "Select a category"
if (!input.hasVariants) errors.variants = "Check at least one variant category and add its values"
return errors
}