From c9a84e235b3565d6c8228d84724fa7c5644e9c9d Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 15 Jul 2026 18:11:35 +0530 Subject: [PATCH] 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. --- Frontend/PROGRESS.md | 22 +- .../app/dashboard/products/brands/page.tsx | 296 ++++++++++ .../dashboard/products/categories/page.tsx | 256 ++++++--- .../app/dashboard/products/new/page.tsx | 509 ++++++++++++++---- .../app/dashboard/products/variants/page.tsx | 216 ++++++++ .../components/Layouts/AppSidebar.tsx | 62 ++- Frontend/erp-system/lib/api/brands.ts | 64 +++ Frontend/erp-system/lib/api/categories.ts | 58 +- Frontend/erp-system/lib/api/items.ts | 4 + Frontend/erp-system/lib/api/mock-data.ts | 30 +- Frontend/erp-system/lib/api/variants.ts | 49 ++ .../erp-system/lib/validations/master-data.ts | 22 + Frontend/erp-system/types/master-data.ts | 39 ++ docs/20-FRONTEND.md | 5 + 14 files changed, 1438 insertions(+), 194 deletions(-) create mode 100644 Frontend/erp-system/app/dashboard/products/brands/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/products/variants/page.tsx create mode 100644 Frontend/erp-system/lib/api/brands.ts create mode 100644 Frontend/erp-system/lib/api/variants.ts diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 3bdc995..709ce4a 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -24,9 +24,11 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). - [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 -- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04 +- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-15:** added debounced search + Previous/Next pagination (`categoriesApi.list()` now takes `page`/`pageSize`/`q`/`sortOrder`, page size 5), matching the Vendor list's pagination pattern. +- [~] Brands (`app/dashboard/products/brands` list + create/edit dialog + delete) — **not a documented FR/endpoint**; `lib/api/brands.ts` treats it as a standalone name-only master, same shape as Categories, since Item has no `brandId` in the doc. **2026-07-15:** added the same debounced search + pagination as Categories; `Item`/`CreateItemRequest`/`ItemListItem` gained `brandId` so the new-item variant builder (above) can attach a brand. +- [~] Variant Categories (`app/dashboard/products/variants` list + create/edit dialog + delete) — **frontend-only, not a documented FR/endpoint.** A flat, name-only master list of variant dimensions (seeded with "Color", "Size") that the Item `/new` variant builder now genuinely drives from (see the Items bullet above) — checking a category there shows its value-entry UI, and a "+" on that same page can create a brand-new category (e.g. "Material") inline via `variantCategoriesApi.create`, which then also shows up back here. Values themselves (Red, Blue, S, M...) are still not managed on this page — only entered per-Item on `/new` — so `variant_values` (the individual Red/Blue/S/M records) still isn't a real backend entity; flag to whoever owns the backend contract if that should change. New `types/master-data.ts` (`VariantCategory`/`CreateVariantCategoryRequest`/`UpdateVariantCategoryRequest`), `lib/api/variants.ts` (`variantCategoriesApi`), `lib/validations/master-data.ts` (`validateVariantCategoryName`). Sidebar gained a "Variant" entry under Products (`components/Layouts/AppSidebar.tsx`). - [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). - [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. - [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 @@ -132,3 +134,19 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. - Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). - Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). + +### 2026-07-15 — Categories/Brands pagination, Item variant builder (Category→Subcategory→Brand→Color/Size), Variant Categories master (frontend-only; no backend changes) +- **Pagination:** `categoriesApi.list()`/`brandsApi.list()` (`lib/api/categories.ts`/`lib/api/brands.ts`) changed from returning everything on one page to real `page`/`pageSize`/`q`/`sortOrder` filtering+slicing (page size 5), matching the Vendor list's existing pattern. Both list screens gained debounced search + Previous/Next controls with a "Showing X–Y of Z" caption. **Follow-on fix:** the `new/page.tsx` item-create form and anywhere else fetching the full category/brand list for a `` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"|"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. +- Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx new file mode 100644 index 0000000..df1d92f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -0,0 +1,296 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } 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 { Brand } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +type SortOrder = "asc" | "desc" + +const PAGE_SIZE = 5 + +export default function BrandsPage() { + const [brands, setBrands] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [sortOrder, setSortOrder] = useState("asc") + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, sortOrder]) + + function load() { + setError(null) + brandsApi + .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .then((res) => { + setBrands(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [search, sortOrder, page]) + + const hasFilters = search.trim().length > 0 + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(brand: Brand) { + setEditing(brand) + setName(brand.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateBrandName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const brand = editing + ? await brandsApi.update(editing.brandId, { name }) + : await brandsApi.create({ name }) + toast.success(editing ? "Brand updated" : "Brand created", brand.name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(brand: Brand) { + setDeletingId(brand.brandId) + try { + await brandsApi.remove(brand.brandId) + toast.success("Brand deleted", brand.name) + load() + } catch (err) { + toast.error("Could not delete brand", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+ + + +
+

Brands

+

Manage product brands.

+
+
+ + + Add Brand} /> + + + {editing ? "Edit brand" : "New brand"} + Give the brand a name. + + + + Name + setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search brands…" + className="h-14 w-full pl-11 text-base" + aria-label="Search brands" + /> +
+ value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> + + + + + Name (A–Z) + Name (Z–A) + + +
+ + {error && ( +
{error}
+ )} + + {!error && brands === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && brands !== null && brands.length === 0 && ( +
+ +

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

+
+ )} + + {!error && brands !== null && brands.length > 0 && ( + <> + + + + ID + Name + Created At + Actions + + + + {brands.map((b) => ( + + #{b.brandId} + {b.name} + {new Date(b.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(b)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 19037b4..18909ec 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,85 +2,121 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ListTree, Plus } from "lucide-react" +import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } 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 { Category, CategoryTreeNode } from "@/types/master-data" +import { PaginationMeta } from "@/types/common" +import { Category } from "@/types/master-data" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { - return ( -
-
- - {node.name} - #{node.categoryId} -
- {node.children.map((child) => ( - - ))} -
- ) -} +type SortOrder = "asc" | "desc" + +const PAGE_SIZE = 5 export default function CategoriesPage() { - const [tree, setTree] = useState(null) - const [flat, setFlat] = useState([]) + const [categories, setCategories] = useState(null) + const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [sortOrder, setSortOrder] = useState("asc") + const [page, setPage] = useState(1) + 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) + + useEffect(() => { + const timeout = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => { + setPage(1) + }, [search, sortOrder]) function load() { setError(null) - Promise.all([categoriesApi.tree(), categoriesApi.list()]) - .then(([t, f]) => { - setTree(t) - setFlat(f.items) + categoriesApi + .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE }) + .then((res) => { + setCategories(res.items) + setPagination(res.pagination) }) .catch((err) => setError(errorMessage(err))) } - useEffect(load, []) + useEffect(load, [search, sortOrder, page]) - async function handleCreate() { + const hasFilters = search.trim().length > 0 + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(category: Category) { + setEditing(category) + setName(category.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { const nextErrors = validateCategoryName(name) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const category = await categoriesApi.create({ name, parentId }) - toast.success("Category created", category.name) + const category = editing + ? await categoriesApi.update(editing.categoryId, { name }) + : await categoriesApi.create({ name }) + toast.success(editing ? "Category updated" : "Category created", category.name) setOpen(false) setName("") - setParentId(null) + setEditing(null) setErrors({}) load() } catch (err) { setErrors({ name: errorMessage(err) }) - toast.error("Could not create category", errorMessage(err)) + toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) } finally { setSubmitting(false) } } + async function handleDelete(category: Category) { + setDeletingId(category.categoryId) + try { + await categoriesApi.remove(category.categoryId) + toast.success("Category deleted", category.name) + load() + } catch (err) { + toast.error("Could not delete category", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + return (
@@ -90,16 +126,16 @@ export default function CategoriesPage() {

Categories

-

Hierarchical item category structure (FR-MD-04).

+

Item category master (FR-MD-04).

- New Category} /> + New Category} /> - New category - Optionally nest it under an existing category. + {editing ? "Edit category" : "New category"} + Give the category a name. @@ -107,59 +143,153 @@ export default function CategoriesPage() { setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> - - Parent (optional) - value={parentId} onValueChange={setParentId}> - - - - - {flat.map((c) => ( - - {c.name} - - ))} - - -
-
+
+
+ + setSearchInput(e.target.value)} + placeholder="Search categories…" + className="h-14 w-full pl-11 text-base" + aria-label="Search categories" + /> +
+ value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> + + + + + Name (A–Z) + Name (Z–A) + + +
+ {error && (
{error}
)} - {!error && tree === null && ( + {!error && categories === null && (
{Array.from({ length: 3 }).map((_, i) => ( - + ))}
)} - {!error && tree !== null && tree.length === 0 && ( + {!error && categories !== null && categories.length === 0 && (
-

No categories yet.

+

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

)} - {!error && tree !== null && tree.length > 0 && ( -
- {tree.map((node) => ( - - ))} -
+ {!error && categories !== null && categories.length > 0 && ( + <> + + + + ID + Name + Created At + Actions + + + + {categories.map((c) => ( + + #{c.categoryId} + {c.name} + {new Date(c.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(c)} + /> + +
+
+
+ ))} +
+
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} + )} ) diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index eda1169..a5666c7 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -1,91 +1,226 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import { ArrowLeft, Plus, X } 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 { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateItemForm } from "@/lib/validations/master-data" +import { brandsApi } from "@/lib/api/brands" +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 { ItemType, TrackingMode } from "@/types/master-data" +import { Category, VariantCategory } from "@/types/master-data" +import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" 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 { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +function skuSegment(text: string, maxLen: number): string { + const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, maxLen) || "GEN" +} + +function buildVariantSku(categoryLabel: string, values: string[]): string { + return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") +} + +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 +} + +// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data). +const DEFAULT_BASE_UOM_ID = 1 + export default function NewItemPage() { const router = useRouter() - const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) - const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) - const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) + const [categories, setCategories] = useState(null) + const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null) + const [variantCategories, setVariantCategories] = useState(null) const [loadError, setLoadError] = useState(null) - const [sku, setSku] = useState("") - const [name, setName] = useState("") - const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) - const [baseUomId, setBaseUomId] = useState(null) - const [defaultVendorId, setDefaultVendorId] = useState(null) - const [itemType, setItemType] = useState("Stocked") - const [trackingMode, setTrackingMode] = useState("None") - const [taxClass, setTaxClass] = useState("STD") + const [subCategoryId, setSubCategoryId] = useState(null) + const [brandId, setBrandId] = useState(null) + + const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState([]) + const [valuesByCategory, setValuesByCategory] = useState>({}) + const [inputByCategory, setInputByCategory] = useState>({}) + const [colorNameByCategory, setColorNameByCategory] = useState>({}) + const [quantities, setQuantities] = useState>({}) + + const [addingCategory, setAddingCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState("") + const [newCategoryError, setNewCategoryError] = useState(null) + const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false) const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) useEffect(() => { - Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) - .then(([cat, uo, ve]) => { + Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()]) + .then(([cat, br, vc]) => { setCategories(cat.items) - setUoms(uo.items) - setVendors(ve.items) + setBrands(br.items) + setVariantCategories(vc.items) }) .catch((err) => setLoadError(errorMessage(err))) }, []) + const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories]) + const subCategoryOptions = useMemo( + () => (categories ?? []).filter((c) => c.parentId === categoryId), + [categories, categoryId] + ) + const effectiveCategoryId = subCategoryId ?? categoryId + const effectiveCategoryLabel = + (categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? "" + const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? "" + + function handleCategoryChange(value: number | null) { + setCategoryId(value) + setSubCategoryId(null) + } + + function toggleVariantCategory(variantCategoryId: number) { + setCheckedVariantCategoryIds((prev) => + prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId] + ) + setQuantities({}) + } + + async function handleAddVariantCategory() { + const nextErrors = validateVariantCategoryName(newCategoryName) + if (nextErrors.name) { + setNewCategoryError(nextErrors.name) + return + } + setAddingCategorySubmitting(true) + try { + const category = await variantCategoriesApi.create({ name: newCategoryName }) + setVariantCategories((prev) => [...(prev ?? []), category]) + setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId]) + setNewCategoryName("") + setNewCategoryError(null) + setAddingCategory(false) + toast.success("Variant category created", category.name) + } catch (err) { + setNewCategoryError(errorMessage(err)) + } finally { + setAddingCategorySubmitting(false) + } + } + + function addValue(variantCategoryId: number, overrideValue?: string) { + const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim() + if (value) { + setValuesByCategory((prev) => { + const existing = prev[variantCategoryId] ?? [] + if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev + return { ...prev, [variantCategoryId]: [...existing, value] } + }) + setQuantities({}) + } + setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" })) + } + + function removeValue(variantCategoryId: number, value: string) { + setValuesByCategory((prev) => ({ + ...prev, + [variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value), + })) + setQuantities({}) + } + + const activeCategories = useMemo( + () => + (variantCategories ?? []) + .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) + .map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] })) + .filter((vc) => vc.values.length > 0), + [variantCategories, checkedVariantCategoryIds, valuesByCategory] + ) + + const variants = useMemo(() => { + if (activeCategories.length === 0) return [] + let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }] + for (const cat of activeCategories) { + const next: typeof combinations = [] + for (const combo of combinations) { + for (const value of cat.values) { + next.push({ + key: combo.key ? `${combo.key}::${value}` : value, + parts: [...combo.parts, { name: cat.name, value }], + }) + } + } + combinations = next + } + return combinations.map((c) => ({ + ...c, + sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)), + })) + }, [activeCategories, effectiveCategoryLabel]) + async function handleSubmit() { setSubmitError(null) - const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const { data: item } = await itemsApi.create({ - sku, - name, - description: description || null, - categoryId: categoryId as number, - baseUomId: baseUomId as number, - defaultVendorId, - itemType, - trackingMode, - taxClass: taxClass || null, - }) - toast.success("Item created", `${item.sku} — ${item.name}`) - router.push(`/dashboard/products/${item.itemId}`) + let created = 0 + for (const variant of variants) { + const qty = Number(quantities[variant.key] || 0) + await itemsApi.create({ + sku: variant.sku, + name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`, + categoryId: effectiveCategoryId as number, + brandId, + baseUomId: DEFAULT_BASE_UOM_ID, + itemType: "Stocked", + trackingMode: "None", + initialQty: Number.isFinite(qty) ? qty : 0, + }) + created += 1 + } + toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`) + router.push("/dashboard/products") } catch (err) { - const fe = fieldErrors(err) - if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) setSubmitError(errorMessage(err)) - toast.error("Could not create item", errorMessage(err)) + toast.error("Could not create variants", errorMessage(err)) } finally { setSubmitting(false) } } - const loading = !categories || !uoms || !vendors + const loading = !categories || !brands || !variantCategories return (
@@ -95,7 +230,7 @@ export default function NewItemPage() {

New Item

-

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+

Category, subcategory, brand, and variant categories (FR-MD-01).

@@ -108,28 +243,14 @@ export default function NewItemPage() { {!loading && ( <>
-
- - setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> - -
-
- - setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> - -
-
- - setDescription(e.target.value)} className="h-12 text-base" /> -
- value={categoryId} onValueChange={setCategoryId}> + value={categoryId} onValueChange={handleCategoryChange}> - {(categories ?? []).map((c) => ( + {topCategories.map((c) => ( {c.name} @@ -139,66 +260,250 @@ export default function NewItemPage() {
- - value={baseUomId} onValueChange={setBaseUomId}> - - - - - {(uoms ?? []).map((u) => ( - - {u.name} - - ))} - - - -
-
- - value={defaultVendorId} onValueChange={setDefaultVendorId}> + + value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}> - + - {(vendors ?? []).map((v) => ( - - {v.code} — {v.name} + {subCategoryOptions.map((c) => ( + + {c.name} ))}
- - setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> -
-
- - value={itemType} onValueChange={(v) => v && setItemType(v)}> + + value={brandId} onValueChange={setBrandId}> - + - Stocked - Non-stocked - Service + {(brands ?? []).map((b) => ( + + {b.name} + + ))}
-
- - value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> - - - - - None - Batch - Serial - - +
+ +
+
+

Variants

+

+ Check the variant categories that apply, then add their values to generate a SKU per combination. +

+ +
+ {(variantCategories ?? []).map((vc) => ( + + ))} + {!addingCategory && ( + + )} +
+ + {addingCategory && ( +
+
+ setNewCategoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddVariantCategory() + } + }} + placeholder="Material" + className="h-11 max-w-xs text-base" + aria-invalid={!!newCategoryError} + autoFocus + /> + + +
+ +
+ )} + + + + {checkedVariantCategoryIds.length > 0 && ( +
+ {(variantCategories ?? []) + .filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId)) + .map((vc) => { + const isColor = isColorCategory(vc.name) + const currentInput = inputByCategory[vc.variantCategoryId] ?? "" + const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? "" + + function addColor() { + const name = currentColorName.trim() + if (!name) return + addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444")) + setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" })) + } + + return ( +
+ +
+ {isColor ? ( + <> + setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: 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, [vc.variantCategoryId]: 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, [vc.variantCategoryId]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addValue(vc.variantCategoryId) + } + }} + placeholder={vc.name} + className="h-11 text-base" + /> + )} + +
+
+ {(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => { + const decoded = isColor ? decodeColorValue(v) : null + return ( + + {decoded && ( + + ) + })} +
+
+ ) + })} +
+ )} + + {variants.length > 0 && ( +
+ + + + {activeCategories.map((cat) => ( + {cat.name} + ))} + SKU + Quantity + + + + {variants.map((variant) => ( + + {variant.parts.map((part, i) => { + const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null + return ( + + + {decoded && ( + + + ) + })} + {variant.sku} + + setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))} + placeholder="0" + className="h-9 w-24 text-sm" + /> + + + ))} + +
+
+ )}
{submitError && ( @@ -210,7 +515,7 @@ export default function NewItemPage() { Cancel
diff --git a/Frontend/erp-system/app/dashboard/products/variants/page.tsx b/Frontend/erp-system/app/dashboard/products/variants/page.tsx new file mode 100644 index 0000000..051ab76 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/variants/page.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react" + +import { variantCategoriesApi } from "@/lib/api/variants" +import { errorMessage } from "@/lib/error-map" +import { validateVariantCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { VariantCategory } from "@/types/master-data" + +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function VariantsPage() { + const [categories, setCategories] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + function load() { + setError(null) + variantCategoriesApi + .list() + .then((res) => setCategories(res.items)) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + function openCreateDialog() { + setEditing(null) + setName("") + setErrors({}) + setOpen(true) + } + + function openEditDialog(category: VariantCategory) { + setEditing(category) + setName(category.name) + setErrors({}) + setOpen(true) + } + + async function handleSubmit() { + const nextErrors = validateVariantCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const category = editing + ? await variantCategoriesApi.update(editing.variantCategoryId, { name }) + : await variantCategoriesApi.create({ name }) + toast.success(editing ? "Variant category updated" : "Variant category created", category.name) + setOpen(false) + setName("") + setEditing(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(category: VariantCategory) { + setDeletingId(category.variantCategoryId) + try { + await variantCategoriesApi.remove(category.variantCategoryId) + toast.success("Variant category deleted", category.name) + load() + } catch (err) { + toast.error("Could not delete category", errorMessage(err)) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+ + + +
+

Variants

+

Variant categories used by the item variant builder (e.g. Color, Size, Material).

+
+
+ + + Add Category} /> + + + {editing ? "Edit variant category" : "New variant category"} + Give the category a name. + + + + Name + setName(e.target.value)} + placeholder="Material" + aria-invalid={!!errors.name} + /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && categories === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && categories !== null && categories.length === 0 && ( +
+ +

No variant categories yet.

+
+ )} + + {!error && categories !== null && categories.length > 0 && ( + + + + ID + Name + Created At + Actions + + + + {categories.map((c) => ( + + #{c.variantCategoryId} + {c.name} + {new Date(c.createdAt).toLocaleDateString()} + +
+ + + + + } + > + + + handleDelete(c)} + /> + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 0236a2f..8bddb2a 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -4,16 +4,20 @@ import { useEffect, useState } from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { + Boxes, Building2, ChevronRight, ClipboardList, HelpCircle, LayoutGrid, + ListTree, Menu, Package, PackageCheck, Settings, ShoppingCart, + SwatchBook, + Tag, Truck, Warehouse, X, @@ -22,9 +26,26 @@ import { import { cn } from "@/lib/utils" -const navItems: { title: string; href: string; icon: LucideIcon; chevron?: boolean }[] = [ +const navItems: { + title: string + href: string + icon: LucideIcon + chevron?: boolean + children?: { title: string; href: string; icon: LucideIcon }[] +}[] = [ { title: "Dashboard", href: "/dashboard", icon: LayoutGrid }, - { title: "Products", href: "/dashboard/products", icon: Package, chevron: true }, + { + title: "Products", + href: "/dashboard/products", + icon: Package, + chevron: true, + children: [ + { title: "Item", href: "/dashboard/products", icon: Boxes }, + { 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: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, { title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, @@ -109,12 +130,47 @@ function SidebarContent({ {(!collapsed || isMobile) && ( <> {item.title} - {item.chevron && !isActive && ( + {item.chevron && !item.children && !isActive && ( )} )} + + {item.children && (!collapsed || isMobile) && ( +
    + {(() => { + // Longest-matching href wins so a shared prefix (e.g. "Item" and + // "Category" both live under /dashboard/products) doesn't light up + // more than one sub-item at once. + const activeChild = [...item.children] + .filter((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) + .sort((a, b) => b.href.length - a.href.length)[0] + return item.children.map((child) => { + const childActive = child.href === activeChild?.href + return ( +
  • + + + {child.title} + +
  • + ) + }) + })()} +
+ )} ) })} diff --git a/Frontend/erp-system/lib/api/brands.ts b/Frontend/erp-system/lib/api/brands.ts new file mode 100644 index 0000000..7d9622b --- /dev/null +++ b/Frontend/erp-system/lib/api/brands.ts @@ -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> { + 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 { + 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 { + 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 { + 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) + }, +} diff --git a/Frontend/erp-system/lib/api/categories.ts b/Frontend/erp-system/lib/api/categories.ts index 45c473a..23b211c 100644 --- a/Frontend/erp-system/lib/api/categories.ts +++ b/Frontend/erp-system/lib/api/categories.ts @@ -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(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> { - const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name)) + list(params: ListCategoriesParams = {}): Promise> { + 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 { - return mockDelay(buildTree(mockCategories)) - }, - create(request: CreateCategoryRequest): Promise { 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 { + 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 { + 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) + }, } diff --git a/Frontend/erp-system/lib/api/items.ts b/Frontend/erp-system/lib/api/items.ts index c31e1e9..2d7fd38 100644 --- a/Frontend/erp-system/lib/api/items.ts +++ b/Frontend/erp-system/lib/api/items.ts @@ -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 diff --git a/Frontend/erp-system/lib/api/mock-data.ts b/Frontend/erp-system/lib/api/mock-data.ts index fb35ae8..32f9e97 100644 --- a/Frontend/erp-system/lib/api/mock-data.ts +++ b/Frontend/erp-system/lib/api/mock-data.ts @@ -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, diff --git a/Frontend/erp-system/lib/api/variants.ts b/Frontend/erp-system/lib/api/variants.ts new file mode 100644 index 0000000..4d275e2 --- /dev/null +++ b/Frontend/erp-system/lib/api/variants.ts @@ -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> { + 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 { + 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 { + 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 { + 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) + }, +} diff --git a/Frontend/erp-system/lib/validations/master-data.ts b/Frontend/erp-system/lib/validations/master-data.ts index 548b514..0afd6e8 100644 --- a/Frontend/erp-system/lib/validations/master-data.ts +++ b/Frontend/erp-system/lib/validations/master-data.ts @@ -50,3 +50,25 @@ export function validateCategoryName(name: string): Record { if (!name.trim()) errors.name = "Category name is required" return errors } + +export function validateBrandName(name: string): Record { + const errors: Record = {} + if (!name.trim()) errors.name = "Brand name is required" + return errors +} + +export function validateVariantCategoryName(name: string): Record { + const errors: Record = {} + if (!name.trim()) errors.name = "Category name is required" + return errors +} + +export function validateVariantItemForm(input: { + categoryId: number | null + hasVariants: boolean +}): Record { + const errors: Record = {} + 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 +} diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts index 83a0457..7a16bd3 100644 --- a/Frontend/erp-system/types/master-data.ts +++ b/Frontend/erp-system/types/master-data.ts @@ -9,6 +9,7 @@ export interface ItemListItem { sku: string name: string categoryId: number + brandId?: number | null baseUomId: number defaultVendorId: number | null itemType: ItemType @@ -46,6 +47,7 @@ export interface Item { name: string description: string | null categoryId: number + brandId?: number | null baseUomId: number defaultVendorId: number | null itemType: ItemType @@ -54,6 +56,8 @@ export interface Item { status: EntityStatus reorder: ItemReorderSetting[] conversions: UomConversion[] + /** Quantity captured at creation time (e.g. from the variant builder). Not wired into the Stock Core ledger — informational only. */ + initialQty?: number | null createdAt: string updatedAt: string | null } @@ -63,11 +67,13 @@ export interface CreateItemRequest { name: string description?: string | null categoryId: number + brandId?: number | null baseUomId: number defaultVendorId?: number | null itemType: ItemType trackingMode: TrackingMode taxClass?: string | null + initialQty?: number | null } export type UpdateItemRequest = CreateItemRequest @@ -124,6 +130,7 @@ export interface Category { categoryId: number name: string parentId: number | null + createdAt: string } export interface CategoryTreeNode extends Category { @@ -134,3 +141,35 @@ export interface CreateCategoryRequest { name: string parentId?: number | null } + +export interface UpdateCategoryRequest { + name: string +} + +export interface Brand { + brandId: number + name: string + createdAt: string +} + +export interface CreateBrandRequest { + name: string +} + +export interface UpdateBrandRequest { + name: string +} + +export interface VariantCategory { + variantCategoryId: number + name: string + createdAt: string +} + +export interface CreateVariantCategoryRequest { + name: string +} + +export interface UpdateVariantCategoryRequest { + name: string +} diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 54ac3c4..3c4167c 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -125,6 +125,11 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a | Adjustment | Adjustment | `POST /stock-adjustments` | | Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` | +### 2.2 Master data screens (supporting, outside the core flow) +Vendors, Items, Categories, UOM, Warehouses, Brands, and Variant Categories are supporting master-data CRUD screens the flow above depends on but doesn't itself route through, so they're intentionally absent from the diagram/table. List screens follow one pagination convention: `page`/`pageSize`/`q`/`sortOrder` params, page size 5, debounced search, Previous/Next controls. + +**Brand** (`app/dashboard/products/brands`) and **Variant Category** (`app/dashboard/products/variants`) are UI-only additions with no corresponding endpoint in `11-BACKEND-PHASE1.md` — Item's `brandId` is built the same way. The Item variant builder on `/dashboard/products/new` reads the Variant Category list live: checking a category (Color, Size, or any custom one added inline from that same page) reveals a value-entry section for it, and one Item is auto-created per combination across however many categories are checked, with an auto-generated SKU. Flag Brand/Variant Category to whoever owns the backend contract if they should become real entities rather than staying frontend-only; see `Frontend/PROGRESS.md` (2026-07-15 entries) for the full rationale and discarded design iterations. + --- ## 3. Validation posture (read carefully) -- 2.52.0