Merge pull request 'feat: add brands and variant categories management' (#8) from inventry-and-supply-chain-management into Dev
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
+20
-2
@@ -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 X–Y of Z" caption. **Follow-on fix:** the `new/page.tsx` item-create form and anywhere else fetching the full category/brand list for a `<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 (A–Z)</SelectItem>
|
||||||
|
<SelectItem value="desc" className="text-base">Name (Z–A)</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 (A–Z)</SelectItem>
|
||||||
|
<SelectItem value="desc" className="text-base">Name (Z–A)</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">
|
||||||
</div>
|
<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>
|
||||||
|
</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",
|
||||||
toast.success("Item created", `${item.sku} — ${item.name}`)
|
initialQty: Number.isFinite(qty) ? qty : 0,
|
||||||
router.push(`/dashboard/products/${item.itemId}`)
|
})
|
||||||
|
created += 1
|
||||||
|
}
|
||||||
|
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">
|
</div>
|
||||||
<Label className="text-base">Tracking mode</Label>
|
|
||||||
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<SelectTrigger className="h-12! w-full text-base">
|
<div>
|
||||||
<SelectValue />
|
<h2 className="text-lg font-semibold text-foreground">Variants</h2>
|
||||||
</SelectTrigger>
|
<p className="text-sm text-muted-foreground">
|
||||||
<SelectContent>
|
Check the variant categories that apply, then add their values to generate a SKU per combination.
|
||||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
</p>
|
||||||
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
|
|
||||||
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts.
|
||||||
|
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
|
||||||
|
import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data"
|
||||||
|
|
||||||
|
export interface ListBrandsParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
sortOrder?: "asc" | "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const brandsApi = {
|
||||||
|
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
|
||||||
|
const term = params.q?.trim().toLowerCase()
|
||||||
|
const sortOrder = params.sortOrder ?? "asc"
|
||||||
|
const filtered = mockBrands
|
||||||
|
.filter((b) => !term || b.name.toLowerCase().includes(term))
|
||||||
|
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
|
||||||
|
|
||||||
|
const page = params.page ?? 1
|
||||||
|
const pageSize = params.pageSize ?? 5
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const items = filtered.slice(start, start + pageSize)
|
||||||
|
const totalItems = filtered.length
|
||||||
|
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
|
||||||
|
|
||||||
|
return mockDelay({
|
||||||
|
items,
|
||||||
|
pagination: { page, pageSize, totalItems, totalPages },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
create(request: CreateBrandRequest): Promise<Brand> {
|
||||||
|
const name = request.name.trim()
|
||||||
|
if (!name) return Promise.reject(new Error("Brand name is required."))
|
||||||
|
if (mockBrands.some((b) => b.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
return Promise.reject(new Error(`Brand "${name}" already exists.`))
|
||||||
|
}
|
||||||
|
const brand: Brand = { brandId: allocateBrandId(), name, createdAt: new Date().toISOString() }
|
||||||
|
mockBrands.push(brand)
|
||||||
|
return mockDelay(brand)
|
||||||
|
},
|
||||||
|
|
||||||
|
update(brandId: number, request: UpdateBrandRequest): Promise<Brand> {
|
||||||
|
const name = request.name.trim()
|
||||||
|
if (!name) return Promise.reject(new Error("Brand name is required."))
|
||||||
|
const brand = mockBrands.find((b) => b.brandId === brandId)
|
||||||
|
if (!brand) return Promise.reject(new Error("Brand not found."))
|
||||||
|
if (mockBrands.some((b) => b.brandId !== brandId && b.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
return Promise.reject(new Error(`Brand "${name}" already exists.`))
|
||||||
|
}
|
||||||
|
brand.name = name
|
||||||
|
return mockDelay(brand)
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(brandId: number): Promise<void> {
|
||||||
|
const index = mockBrands.findIndex((b) => b.brandId === brandId)
|
||||||
|
if (index === -1) return Promise.reject(new Error("Brand not found."))
|
||||||
|
mockBrands.splice(index, 1)
|
||||||
|
return mockDelay(undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
|
// 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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user