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:
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user