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
+20 -2
View File
@@ -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 - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API
## 2. Master Data screens ## 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 `<CategoryCode>-<value1Code>-<value2Code>...`; item name is `<Brand> <Category> - <value1>/<value2>...`. 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 - [~] 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<T>` was built earlier but unused until now). - [~] 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<T>` 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. - [~] 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 - [~] 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. - **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). - 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). - 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 XY of Z" caption. **Follow-on fix:** the `new/page.tsx` item-create form and anywhere else fetching the full category/brand list for a `<Select>` had to be updated to pass `{ pageSize: 200 }` explicitly, since the new default of 5 would otherwise silently truncate those dropdowns.
- **Item variant builder** (`app/dashboard/products/new/page.tsx`): added a Category (top-level, `parentId === null`) → Subcategory (children of the chosen category) → Brand picker ahead of the existing Base UOM/vendor/type/tracking fields, plus a "Variants" panel — free-text Color and Size chip inputs ("Add Color"/"Add Size" buttons) build a matrix table (rows = colors, columns = sizes; each cell shows an auto-generated SKU `<CategoryOrSubcategoryCode>-<Color>-<Size>`, e.g. `FAS-RED-S`, plus an editable Quantity). Submitting with variants present loops `itemsApi.create()` once per Color×Size cell and redirects to the Items list; submitting with no variants added falls back to the original single-item create/redirect-to-detail behavior unchanged. `types/master-data.ts`: added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem`**deviation**, neither is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured per variant but **not** wired into the Stock Core ledger/GRN — informational only until a real "initial receipt" flow exists.
- **Variant Categories master** (`app/dashboard/products/variants/page.tsx`, new sidebar entry "Variant" under Products): landed, after several discarded iterations mid-session (a Color/Size quick-select wired into the item builder, a hex-color-picker + Color/Size matrix table, a two-table Colors/Sizes toggle — all removed per follow-up user feedback), on a plain name-only CRUD list of **Variant Categories** (seeded "Color", "Size"; e.g. "Material" can be added), same list/create/edit/delete shape as Categories/Brands. New `types/master-data.ts` (`VariantCategory`/`CreateVariantCategoryRequest`/`UpdateVariantCategoryRequest`), new `lib/api/variants.ts` (`variantCategoriesApi`), `lib/validations/master-data.ts` gained `validateVariantCategoryName`. **Not a documented FR/endpoint** — flag to whoever owns the backend contract if per-category values (the actual Red/Blue/S/M list) should become a real `variant_categories`/`variant_values` entity rather than staying a UI-only name list feeding the item builder's free-text chips.
- Verified: `tsc --noEmit` clean throughout every step (same pre-existing `login/page.tsx` resolver-typing error only); each UI change was screenshotted end-to-end via a headless Playwright session against the dev server (pagination Prev/Next + counts, category/subcategory/brand selection, color/size chip entry → matrix table → generated SKUs, submit → created items appearing in the Items list, variant-category create/edit/delete) with `console --errors` checked clean at every step.
### 2026-07-15 (continued) — New Item form pared down + variant builder generalized to dynamic Variant Categories (frontend-only; no backend changes)
- **Same-day follow-up, superseding parts of the entry above** — the Item variant builder went through several more rounds of user-driven refinement after the initial Color/Size-hardcoded version landed:
1. **Field removal:** SKU, Name, Description, Default vendor, Tax class, Item type, and Tracking mode were all removed from `/new`'s UI on request. Since there's no manual SKU/Name anymore, the form now *always* operates in variant mode (the old "no variants → fall back to single-item create" branch is gone) — Item type/Tracking mode/Base UOM became fixed constants (`"Stocked"`/`"None"`/`uomId 1`) baked into every `itemsApi.create()` call instead of user-facing fields. New `validateVariantItemForm` (`lib/validations/master-data.ts`) replaced the old `validateItemForm` call on this page (that function is still used, unchanged, by the Item **edit** page at `/[id]`, which keeps its SKU/Name fields — this removal is `/new`-only).
2. **Base UOM removed** (a separate follow-up ask) — same treatment, folded into the `DEFAULT_BASE_UOM_ID = 1` constant above.
3. **Subcategory made unconditional** — previously hidden entirely when the selected category had no children; now always rendered, just disabled with a "No subcategories" placeholder in that case.
4. **Color/Size hardcoding replaced with dynamic Variant Categories:** the builder now fetches `variantCategoriesApi.list()` and renders one checkbox per category (Color, Size, or any custom one); checking a box reveals its value-entry section instead of two fixed Color/Size blocks. The variant table generalized from the old 2-column Color×Size matrix to a flat table with one column per *checked* category + SKU + Quantity, built via a generic cartesian-product `useMemo` over however many categories are active (1, 2, or more) — `buildVariantSku` now takes an array of value labels instead of two fixed color/size params.
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `<input type="color">` 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 `"<name>|<hex>"` 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.
@@ -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<Brand[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
<p className="text-base text-muted-foreground">Manage product brands.</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
<DialogDescription>Give the brand a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="brand-name">Name</FieldLabel>
<Input id="brand-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search brands…"
className="h-14 w-full pl-11 text-base"
aria-label="Search brands"
/>
</div>
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc" className="text-base">Name (AZ)</SelectItem>
<SelectItem value="desc" className="text-base">Name (ZA)</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && brands === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && brands !== null && brands.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No brands match your search." : "No brands yet."}
</p>
</div>
)}
{!error && brands !== null && brands.length > 0 && (
<>
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{brands.map((b) => (
<TableRow key={b.brandId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${b.name}`}
onClick={() => openEditDialog(b)}
>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${b.name}?`}
description="This permanently removes the brand."
confirmLabel="Delete"
onConfirm={() => handleDelete(b)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -2,85 +2,121 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" 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 { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data" import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { type SortOrder = "asc" | "desc"
return (
<div className="flex flex-col"> const PAGE_SIZE = 5
<div
className="flex items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/50"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
<ListTree className="size-4 text-muted-foreground" />
<span className="text-base font-medium text-foreground">{node.name}</span>
<span className="text-sm text-muted-foreground">#{node.categoryId}</span>
</div>
{node.children.map((child) => (
<TreeNode key={child.categoryId} node={child} depth={depth + 1} />
))}
</div>
)
}
export default function CategoriesPage() { export default function CategoriesPage() {
const [tree, setTree] = useState<CategoryTreeNode[] | null>(null) const [categories, setCategories] = useState<Category[] | null>(null)
const [flat, setFlat] = useState<Category[]>([]) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [name, setName] = useState("") const [name, setName] = useState("")
const [parentId, setParentId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, sortOrder])
function load() { function load() {
setError(null) setError(null)
Promise.all([categoriesApi.tree(), categoriesApi.list()]) categoriesApi
.then(([t, f]) => { .list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
setTree(t) .then((res) => {
setFlat(f.items) setCategories(res.items)
setPagination(res.pagination)
}) })
.catch((err) => setError(errorMessage(err))) .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) const nextErrors = validateCategoryName(name)
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
setSubmitting(true) setSubmitting(true)
try { try {
const category = await categoriesApi.create({ name, parentId }) const category = editing
toast.success("Category created", category.name) ? await categoriesApi.update(editing.categoryId, { name })
: await categoriesApi.create({ name })
toast.success(editing ? "Category updated" : "Category created", category.name)
setOpen(false) setOpen(false)
setName("") setName("")
setParentId(null) setEditing(null)
setErrors({}) setErrors({})
load() load()
} catch (err) { } catch (err) {
setErrors({ name: errorMessage(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 { } finally {
setSubmitting(false) 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -90,16 +126,16 @@ export default function CategoriesPage() {
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Categories</h1> <h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Hierarchical item category structure (FR-MD-04).</p> <p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
</div> </div>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Category</Button>} /> <DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center"> <DialogHeader className="items-center text-center">
<DialogTitle>New category</DialogTitle> <DialogTitle>{editing ? "Edit category" : "New category"}</DialogTitle>
<DialogDescription>Optionally nest it under an existing category.</DialogDescription> <DialogDescription>Give the category a name.</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field data-invalid={!!errors.name}> <Field data-invalid={!!errors.name}>
@@ -107,59 +143,153 @@ export default function CategoriesPage() {
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> <Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field> </Field>
<Field>
<FieldLabel htmlFor="cat-parent">Parent (optional)</FieldLabel>
<Select<number | null> value={parentId} onValueChange={setParentId}>
<SelectTrigger id="cat-parent" className="w-full">
<SelectValue placeholder="None — top-level category" />
</SelectTrigger>
<SelectContent>
{flat.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup> </FieldGroup>
<div className="flex justify-center gap-3 pt-2"> <div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel Cancel
</Button> </Button>
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}> <Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create"} {submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search categories…"
className="h-14 w-full pl-11 text-base"
aria-label="Search categories"
/>
</div>
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc" className="text-base">Name (AZ)</SelectItem>
<SelectItem value="desc" className="text-base">Name (ZA)</SelectItem>
</SelectContent>
</Select>
</div>
{error && ( {error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)} )}
{!error && tree === null && ( {!error && categories === null && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" /> <Skeleton key={i} className="h-14 w-full" />
))} ))}
</div> </div>
)} )}
{!error && tree !== null && tree.length === 0 && ( {!error && categories !== null && categories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center"> <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ListTree className="size-12 text-muted-foreground" /> <ListTree className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No categories yet.</p> <p className="text-base text-muted-foreground">
{hasFilters ? "No categories match your search." : "No categories yet."}
</p>
</div> </div>
)} )}
{!error && tree !== null && tree.length > 0 && ( {!error && categories !== null && categories.length > 0 && (
<div className="flex flex-col rounded-xl border p-3"> <>
{tree.map((node) => ( <Table className="text-base">
<TreeNode key={node.categoryId} node={node} depth={0} /> <TableHeader className="bg-indigo-50">
))} <TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((c) => (
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div> </div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)} )}
</div> </div>
) )
@@ -1,91 +1,226 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft } from "lucide-react" import { ArrowLeft, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories" import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms" import { brandsApi } from "@/lib/api/brands"
import { vendorsApi } from "@/lib/api/vendors" import { variantCategoriesApi } from "@/lib/api/variants"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data" import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field" import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" 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() { export default function NewItemPage() {
const router = useRouter() const router = useRouter()
const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) const [categories, setCategories] = useState<Category[] | null>(null)
const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) const [variantCategories, setVariantCategories] = useState<VariantCategory[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [sku, setSku] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null) const [brandId, setBrandId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None") const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState<number[]>([])
const [taxClass, setTaxClass] = useState("STD") const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
const [quantities, setQuantities] = useState<Record<string, string>>({})
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
const [newCategoryError, setNewCategoryError] = useState<string | null>(null)
const [addingCategorySubmitting, setAddingCategorySubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
useEffect(() => { useEffect(() => {
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
.then(([cat, uo, ve]) => { .then(([cat, br, vc]) => {
setCategories(cat.items) setCategories(cat.items)
setUoms(uo.items) setBrands(br.items)
setVendors(ve.items) setVariantCategories(vc.items)
}) })
.catch((err) => setLoadError(errorMessage(err))) .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() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return if (Object.keys(nextErrors).length > 0) return
setSubmitting(true) setSubmitting(true)
try { try {
const { data: item } = await itemsApi.create({ let created = 0
sku, for (const variant of variants) {
name, const qty = Number(quantities[variant.key] || 0)
description: description || null, await itemsApi.create({
categoryId: categoryId as number, sku: variant.sku,
baseUomId: baseUomId as number, name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
defaultVendorId, categoryId: effectiveCategoryId as number,
itemType, brandId,
trackingMode, baseUomId: DEFAULT_BASE_UOM_ID,
taxClass: taxClass || null, itemType: "Stocked",
trackingMode: "None",
initialQty: Number.isFinite(qty) ? qty : 0,
}) })
toast.success("Item created", `${item.sku}${item.name}`) created += 1
router.push(`/dashboard/products/${item.itemId}`) }
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
router.push("/dashboard/products")
} catch (err) { } catch (err) {
const fe = fieldErrors(err)
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
setSubmitError(errorMessage(err)) setSubmitError(errorMessage(err))
toast.error("Could not create item", errorMessage(err)) toast.error("Could not create variants", errorMessage(err))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
const loading = !categories || !uoms || !vendors const loading = !categories || !brands || !variantCategories
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -95,7 +230,7 @@ export default function NewItemPage() {
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1> <h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">SKU, category, base UOM, item type, and tracking mode (FR-MD-01).</p> <p className="text-base text-muted-foreground">Category, subcategory, brand, and variant categories (FR-MD-01).</p>
</div> </div>
</div> </div>
@@ -108,28 +243,14 @@ export default function NewItemPage() {
{!loading && ( {!loading && (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" />
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Category</Label> <Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId}> <Select<number | null> value={categoryId} onValueChange={handleCategoryChange}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}> <SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder="Select category" /> <SelectValue placeholder="Select category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(categories ?? []).map((c) => ( {topCategories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base"> <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name} {c.name}
</SelectItem> </SelectItem>
@@ -139,66 +260,250 @@ export default function NewItemPage() {
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} /> <FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label> <Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId}> <Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Default vendor (optional)</Label>
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId}>
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="None" /> <SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(vendors ?? []).map((v) => ( {subCategoryOptions.map((c) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base"> <SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{v.code} {v.name} {c.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Tax class (optional)</Label> <Label className="text-base">Brand (optional)</Label>
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> <Select<number | null> value={brandId} onValueChange={setBrandId}>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)}>
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue /> <SelectValue placeholder="Select brand" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem> {(brands ?? []).map((b) => (
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem> <SelectItem key={b.brandId} value={b.brandId} className="text-base">
<SelectItem value="Service" className="text-base">Service</SelectItem> {b.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variants</h2>
<p className="text-sm text-muted-foreground">
Check the variant categories that apply, then add their values to generate a SKU per combination.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{(variantCategories ?? []).map((vc) => (
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
<Checkbox
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
/>
<span className="text-base font-medium">{vc.name}</span>
</label>
))}
{!addingCategory && (
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another variant category"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
</Button>
)}
</div>
{addingCategory && (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
value={newCategoryName}
onChange={(e) => 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
/>
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
<Plus className="size-4" />
Add
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Cancel"
onClick={() => {
setAddingCategory(false)
setNewCategoryName("")
setNewCategoryError(null)
}}
>
<X className="size-4" />
</Button>
</div>
<FieldError errors={[newCategoryError ? { message: newCategoryError } : undefined]} />
</div>
)}
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedVariantCategoryIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(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 (
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
<Label className="text-base">{vc.name} values</Label>
<div className="flex gap-2">
{isColor ? (
<>
<input
type="color"
value={currentInput || "#EF4444"}
onChange={(e) => 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"
/>
<Input
value={currentColorName}
onChange={(e) => 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"
/>
</>
) : (
<Input
value={currentInput}
onChange={(e) => 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"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(vc.variantCategoryId))}>
<Plus className="size-4" />
Add {vc.name}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
const decoded = isColor ? decodeColorValue(v) : null
return (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
{decoded && (
<span
className="size-3.5 shrink-0 rounded-full border border-black/10"
style={{ backgroundColor: decoded.hex }}
aria-hidden="true"
/>
)}
{decoded ? decoded.name : v}
<button
type="button"
onClick={() => removeValue(vc.variantCategoryId, v)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${decoded ? decoded.name : v}`}
>
<X className="size-3" />
</button>
</Badge>
)
})}
</div>
</div>
)
})}
</div>
)}
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
{activeCategories.map((cat) => (
<TableHead key={cat.variantCategoryId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm text-indigo-700">Quantity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{variants.map((variant) => (
<TableRow key={variant.key}>
{variant.parts.map((part, i) => {
const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null
return (
<TableCell key={i} className="px-3 py-2.5">
<span className="inline-flex items-center gap-1.5">
{decoded && (
<span
className="size-3.5 shrink-0 rounded-full border border-black/10"
style={{ backgroundColor: decoded.hex }}
aria-hidden="true"
/>
)}
{partLabel(part)}
</span>
</TableCell>
)
})}
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
value={quantities[variant.key] ?? ""}
onChange={(e) => setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))}
placeholder="0"
className="h-9 w-24 text-sm"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div> </div>
{submitError && ( {submitError && (
@@ -210,7 +515,7 @@ export default function NewItemPage() {
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}> <Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Item"} {submitting ? "Creating…" : "Create Variants"}
</Button> </Button>
</div> </div>
</> </>
@@ -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<VariantCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<VariantCategory | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Category</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
<Input
id="variant-category-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Material"
aria-invalid={!!errors.name}
/>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && categories !== null && categories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No variant categories yet.</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((c) => (
<TableRow key={c.variantCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.variantCategoryId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the variant category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -4,16 +4,20 @@ import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { import {
Boxes,
Building2, Building2,
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
HelpCircle, HelpCircle,
LayoutGrid, LayoutGrid,
ListTree,
Menu, Menu,
Package, Package,
PackageCheck, PackageCheck,
Settings, Settings,
ShoppingCart, ShoppingCart,
SwatchBook,
Tag,
Truck, Truck,
Warehouse, Warehouse,
X, X,
@@ -22,9 +26,26 @@ import {
import { cn } from "@/lib/utils" 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: "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: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
{ title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, { title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
{ title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
@@ -109,12 +130,47 @@ function SidebarContent({
{(!collapsed || isMobile) && ( {(!collapsed || isMobile) && (
<> <>
<span className="flex-1">{item.title}</span> <span className="flex-1">{item.title}</span>
{item.chevron && !isActive && ( {item.chevron && !item.children && !isActive && (
<ChevronRight className="size-4 shrink-0 text-slate-300" /> <ChevronRight className="size-4 shrink-0 text-slate-300" />
)} )}
</> </>
)} )}
</Link> </Link>
{item.children && (!collapsed || isMobile) && (
<ul className="mt-1 flex flex-col gap-0.5 pl-11">
{(() => {
// 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 (
<li key={child.href}>
<Link
href={child.href}
onClick={onClose}
className={cn(
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
childActive
? "bg-indigo-50 text-indigo-600"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
)}
>
<child.icon
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
/>
{child.title}
</Link>
</li>
)
})
})()}
</ul>
)}
</li> </li>
) )
})} })}
+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). // 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. // In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common" 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" import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
function buildTree(categories: Category[]): CategoryTreeNode[] { export interface ListCategoriesParams {
const nodes = new Map<number, CategoryTreeNode>(categories.map((c) => [c.categoryId, { ...c, children: [] }])) page?: number
const roots: CategoryTreeNode[] = [] pageSize?: number
for (const node of nodes.values()) { q?: string
if (node.parentId !== null && nodes.has(node.parentId)) { sortOrder?: "asc" | "desc"
nodes.get(node.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
} }
export const categoriesApi = { export const categoriesApi = {
list(): Promise<PagedResponse<Category>> { list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
const items = [...mockCategories].sort((a, b) => a.name.localeCompare(b.name)) 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({ return mockDelay({
items, 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> { create(request: CreateCategoryRequest): Promise<Category> {
const name = request.name.trim() const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required.")) 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)) { if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
return Promise.reject(new Error("Selected parent category does not exist.")) 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) mockCategories.push(category)
return mockDelay(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, sku: item.sku,
name: item.name, name: item.name,
categoryId: item.categoryId, categoryId: item.categoryId,
brandId: item.brandId ?? null,
baseUomId: item.baseUomId, baseUomId: item.baseUomId,
defaultVendorId: item.defaultVendorId, defaultVendorId: item.defaultVendorId,
itemType: item.itemType, itemType: item.itemType,
@@ -89,6 +90,7 @@ export const itemsApi = {
name: request.name.trim(), name: request.name.trim(),
description: request.description?.trim() || null, description: request.description?.trim() || null,
categoryId: request.categoryId, categoryId: request.categoryId,
brandId: request.brandId ?? null,
baseUomId: request.baseUomId, baseUomId: request.baseUomId,
defaultVendorId: request.defaultVendorId ?? null, defaultVendorId: request.defaultVendorId ?? null,
itemType: request.itemType, itemType: request.itemType,
@@ -97,6 +99,7 @@ export const itemsApi = {
status: "Active", status: "Active",
reorder: [], reorder: [],
conversions: [], conversions: [],
initialQty: request.initialQty ?? null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: null, updatedAt: null,
} }
@@ -121,6 +124,7 @@ export const itemsApi = {
item.name = request.name.trim() item.name = request.name.trim()
item.description = request.description?.trim() || null item.description = request.description?.trim() || null
item.categoryId = request.categoryId item.categoryId = request.categoryId
item.brandId = request.brandId ?? null
item.baseUomId = request.baseUomId item.baseUomId = request.baseUomId
item.defaultVendorId = request.defaultVendorId ?? null item.defaultVendorId = request.defaultVendorId ?? null
item.itemType = request.itemType 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 // 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 // fetch-based backend connection (lib/api-client.ts and lib/auth-token.ts were
// removed). Shapes mirror docs/11-BACKEND-PHASE1.md. // 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 { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
import { Grn } from "@/types/grn" import { Grn } from "@/types/grn"
import { import {
@@ -51,9 +51,9 @@ export function allocateUomId() {
} }
export const mockCategories: Category[] = [ export const mockCategories: Category[] = [
{ categoryId: 3, name: "Hardware", parentId: null }, { categoryId: 3, name: "Hardware", parentId: null, createdAt: "2026-06-01T08:00:00Z" },
{ categoryId: 12, name: "Fasteners", parentId: 3 }, { categoryId: 12, name: "Fasteners", parentId: 3, createdAt: "2026-06-01T08:05:00Z" },
{ categoryId: 20, name: "Power Tools", parentId: null }, { categoryId: 20, name: "Power Tools", parentId: null, createdAt: "2026-06-02T09:00:00Z" },
] ]
let nextCategoryId = 21 let nextCategoryId = 21
@@ -62,6 +62,28 @@ export function allocateCategoryId() {
return nextCategoryId++ 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[] = [ export const mockVendors: Vendor[] = [
{ {
vendorId: 5, 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" if (!name.trim()) errors.name = "Category name is required"
return errors 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
}
+39
View File
@@ -9,6 +9,7 @@ export interface ItemListItem {
sku: string sku: string
name: string name: string
categoryId: number categoryId: number
brandId?: number | null
baseUomId: number baseUomId: number
defaultVendorId: number | null defaultVendorId: number | null
itemType: ItemType itemType: ItemType
@@ -46,6 +47,7 @@ export interface Item {
name: string name: string
description: string | null description: string | null
categoryId: number categoryId: number
brandId?: number | null
baseUomId: number baseUomId: number
defaultVendorId: number | null defaultVendorId: number | null
itemType: ItemType itemType: ItemType
@@ -54,6 +56,8 @@ export interface Item {
status: EntityStatus status: EntityStatus
reorder: ItemReorderSetting[] reorder: ItemReorderSetting[]
conversions: UomConversion[] 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 createdAt: string
updatedAt: string | null updatedAt: string | null
} }
@@ -63,11 +67,13 @@ export interface CreateItemRequest {
name: string name: string
description?: string | null description?: string | null
categoryId: number categoryId: number
brandId?: number | null
baseUomId: number baseUomId: number
defaultVendorId?: number | null defaultVendorId?: number | null
itemType: ItemType itemType: ItemType
trackingMode: TrackingMode trackingMode: TrackingMode
taxClass?: string | null taxClass?: string | null
initialQty?: number | null
} }
export type UpdateItemRequest = CreateItemRequest export type UpdateItemRequest = CreateItemRequest
@@ -124,6 +130,7 @@ export interface Category {
categoryId: number categoryId: number
name: string name: string
parentId: number | null parentId: number | null
createdAt: string
} }
export interface CategoryTreeNode extends Category { export interface CategoryTreeNode extends Category {
@@ -134,3 +141,35 @@ export interface CreateCategoryRequest {
name: string name: string
parentId?: number | null 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
}
+5
View File
@@ -125,6 +125,11 @@ Each screen calls the endpoints in `11-BACKEND-PHASE1.md`. System steps (blue) a
| Adjustment | Adjustment | `POST /stock-adjustments` | | Adjustment | Adjustment | `POST /stock-adjustments` |
| Count | Count | `POST /stock-counts`, `PUT /stock-counts/{id}/counts`, `POST /stock-counts/{id}/post` | | 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) ## 3. Validation posture (read carefully)