feat: enhance product management features

- Implement sorting and filtering for categories in the dashboard.
- Add status filtering and sorting capabilities for categories.
- Introduce warehouse selection and base UOM configuration in the new item creation page.
- Fetch and display brands and subcategories in the items page.
- Update product settings to manage subcategories and brands.
- Refactor sidebar navigation to include configuration options.
This commit is contained in:
2026-07-20 17:11:13 +05:30
parent fe9e8a780f
commit 295ec5799f
7 changed files with 449 additions and 604 deletions
@@ -3,17 +3,16 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" import { AlertTriangle, ArrowLeft, Save } 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 { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data" import { Item, StockNature, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -21,30 +20,9 @@ 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
interface ReorderDraft {
key: string
warehouseId: number | null
reorderPoint: string
reorderQty: string
}
interface ConversionDraft {
key: string
fromUom: number | null
toUom: number | null
factor: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `row-${keySeq}`
}
export default function ItemDetailPage() { export default function ItemDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
@@ -54,13 +32,13 @@ export default function ItemDetailPage() {
const [etag, setEtag] = useState<string | null>(null) const [etag, setEtag] = useState<string | null>(null)
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
// Basic info form // Basic info form
const [sku, setSku] = useState("") const [sku, setSku] = useState("")
const [name, setName] = useState("") const [name, setName] = useState("")
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand. // Carried through edits so a save doesn't silently drop the item's subcategory/brand.
@@ -68,10 +46,15 @@ export default function ItemDetailPage() {
const [subCategoryId, setSubCategoryId] = useState<number | null>(null) const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null) const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked") const [stockNature, setStockNature] = useState<StockNature>("Stocked")
// Default vendor, tax class, and tracking mode are no longer editable on this page —
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None") const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("") const [taxClass, setTaxClass] = useState("")
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
// isn't sent on save — nothing to wire it to server-side.
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false) const [conflict, setConflict] = useState(false)
@@ -79,18 +62,6 @@ export default function ItemDetailPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false) const [togglingStatus, setTogglingStatus] = useState(false)
// Reorder settings
const [reorderLines, setReorderLines] = useState<ReorderDraft[]>([])
const [reorderErrors, setReorderErrors] = useState<Record<string, Record<string, string>>>({})
const [reorderSaveError, setReorderSaveError] = useState<string | null>(null)
const [savingReorder, setSavingReorder] = useState(false)
// UOM conversions
const [conversionLines, setConversionLines] = useState<ConversionDraft[]>([])
const [conversionErrors, setConversionErrors] = useState<Record<string, Record<string, string>>>({})
const [conversionSaveError, setConversionSaveError] = useState<string | null>(null)
const [savingConversions, setSavingConversions] = useState(false)
function applyItem(data: Item) { function applyItem(data: Item) {
setItem(data) setItem(data)
setSku(data.sku) setSku(data.sku)
@@ -104,8 +75,6 @@ export default function ItemDetailPage() {
setStockNature(data.stockNature) setStockNature(data.stockNature)
setTrackingMode(data.trackingMode) setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "") setTaxClass(data.taxClass ?? "")
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
} }
function load() { function load() {
@@ -123,11 +92,10 @@ export default function ItemDetailPage() {
useEffect(() => { useEffect(() => {
if (!Number.isFinite(itemId)) return if (!Number.isFinite(itemId)) return
load() load()
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
.then(([cat, uo, ve, wh]) => { .then(([cat, uo, wh]) => {
setCategories(cat.items) setCategories(cat.items)
setUoms(uo.items) setUoms(uo.items)
setVendors(ve.items)
setWarehouses(wh.items) setWarehouses(wh.items)
}) })
.catch(() => {}) .catch(() => {})
@@ -183,83 +151,6 @@ export default function ItemDetailPage() {
} }
} }
function updateReorderLine(key: string, patch: Partial<ReorderDraft>) {
setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeReorderLine(key: string) {
setReorderLines((prev) => prev.filter((l) => l.key !== key))
}
async function handleSaveReorder() {
if (!item) return
setReorderSaveError(null)
const nextErrors: Record<string, Record<string, string>> = {}
for (const line of reorderLines) {
const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty })
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
}
setReorderErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setReorderSaveError("Fix the highlighted rows before saving.")
return
}
const settings: ItemReorderSetting[] = reorderLines.map((l) => ({
warehouseId: l.warehouseId as number,
reorderPoint: Number(l.reorderPoint),
reorderQty: Number(l.reorderQty),
}))
setSavingReorder(true)
try {
const result = await itemsApi.updateReorder(item.itemId, { settings })
setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev))
toast.success("Reorder settings saved")
} catch (err) {
setReorderSaveError(errorMessage(err))
toast.error("Could not save reorder settings", errorMessage(err))
} finally {
setSavingReorder(false)
}
}
function updateConversionLine(key: string, patch: Partial<ConversionDraft>) {
setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeConversionLine(key: string) {
setConversionLines((prev) => prev.filter((l) => l.key !== key))
}
async function handleSaveConversions() {
if (!item) return
setConversionSaveError(null)
const nextErrors: Record<string, Record<string, string>> = {}
for (const line of conversionLines) {
const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor })
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
}
setConversionErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setConversionSaveError("Fix the highlighted rows before saving.")
return
}
const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) }))
setSavingConversions(true)
try {
const result = await itemsApi.updateUomConversions(item.itemId, { conversions })
setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev))
setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
toast.success("UOM conversions saved")
} catch (err) {
setConversionSaveError(errorMessage(err))
toast.error("Could not save UOM conversions", errorMessage(err))
} finally {
setSavingConversions(false)
}
}
function uomName(uomId: number) { function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
} }
@@ -339,13 +230,14 @@ export default function ItemDetailPage() {
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> <Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} /> <FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div> </div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Description</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} />
</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} disabled={conflict}> <Select<number | null>
value={categoryId}
onValueChange={setCategoryId}
disabled={conflict}
items={categories.map((c) => ({ label: c.name, value: c.categoryId }))}
>
<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>
@@ -361,7 +253,12 @@ export default function ItemDetailPage() {
</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">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> <Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
disabled={conflict}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}> <SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
<SelectValue placeholder="Select base UOM" /> <SelectValue placeholder="Select base UOM" />
</SelectTrigger> </SelectTrigger>
@@ -375,25 +272,6 @@ export default function ItemDetailPage() {
</Select> </Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} /> <FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div> </div>
<div className="flex flex-col gap-2">
<Label className="text-base">Default vendor</Label>
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{vendors.map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax class</Label>
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* "Item type" now means a Color/Size dimension master — this field is the {/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */} stock-nature one it used to be confused with (docs/11 §8). */}
@@ -410,15 +288,22 @@ export default function ItemDetailPage() {
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label> <Label className="text-base">Warehouse (optional)</Label>
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> <Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
disabled={conflict}
items={warehouses.map((w) => ({ label: `${w.code}${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue /> <SelectValue placeholder="Select warehouse" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem> {warehouses.map((w) => (
<SelectItem value="Batch" className="text-base">Batch</SelectItem> <SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
<SelectItem value="Serial" className="text-base">Serial</SelectItem> {w.code} {w.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -434,165 +319,8 @@ export default function ItemDetailPage() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
<p className="text-sm text-muted-foreground">Per-warehouse reorder point and quantity (FR-MD-05).</p>
</div>
<Button type="button" variant="outline" onClick={() => setReorderLines((prev) => [...prev, { key: newKey(), warehouseId: null, reorderPoint: "", reorderQty: "" }])}>
<Plus className="size-5" />
Add row
</Button>
</div>
{reorderLines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder qty</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{reorderLines.map((line) => {
const errs = reorderErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.warehouseId ? { message: errs.warehouseId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.reorderPoint} aria-invalid={!!errs.reorderPoint} onChange={(e) => updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.reorderPoint ? { message: errs.reorderPoint } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.reorderQty} aria-invalid={!!errs.reorderQty} onChange={(e) => updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.reorderQty ? { message: errs.reorderQty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeReorderLine(line.key)} aria-label="Remove row">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
{reorderSaveError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{reorderSaveError}</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSaveReorder} disabled={savingReorder}>
{savingReorder ? "Saving…" : "Save reorder settings"}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">UOM conversions</h2>
<p className="text-sm text-muted-foreground">Purchase/stock UOM base UOM conversion factors (FR-MD-02/03).</p>
</div>
<Button type="button" variant="outline" onClick={() => setConversionLines((prev) => [...prev, { key: newKey(), fromUom: null, toUom: item.baseUomId, factor: "" }])}>
<Plus className="size-5" />
Add row
</Button>
</div>
{conversionLines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">From UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">To UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Factor</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{conversionLines.map((line) => {
const errs = conversionErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.fromUom}>
<SelectValue placeholder="From" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.fromUom ? { message: errs.fromUom } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.toUom}>
<SelectValue placeholder="To" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.toUom ? { message: errs.toUom } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.factor} aria-invalid={!!errs.factor} onChange={(e) => updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.factor ? { message: errs.factor } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeConversionLine(line.key)} aria-label="Remove row">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
{conversionSaveError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{conversionSaveError}</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSaveConversions} disabled={savingConversions}>
{savingConversions ? "Saving…" : "Save conversions"}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."} {uomName(item.baseUomId)} is the base UOM every transaction converts to and stores quantities in base UOM (FR-MD-03).
</p> </p>
</div> </div>
) )
@@ -2,13 +2,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands" import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateBrandName } from "@/lib/validations/master-data" import { validateBrandName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data" import { Brand } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
type SortOrder = "asc" | "desc" type SortOrder = "asc" | "desc"
type SortKey = "brandId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 5 const PAGE_SIZE = 5
@@ -33,8 +35,12 @@ export default function BrandsPage() {
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Brand | null>(null) const [editing, setEditing] = useState<Brand | null>(null)
@@ -50,12 +56,12 @@ export default function BrandsPage() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [search, sortOrder]) }, [search, status])
function load() { function load() {
setError(null) setError(null)
brandsApi brandsApi
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => { .then((res) => {
setBrands(res.items) setBrands(res.items)
setPagination(res.pagination) setPagination(res.pagination)
@@ -63,9 +69,30 @@ export default function BrandsPage() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
useEffect(load, [search, sortOrder, page]) useEffect(load, [search, status, page])
const hasFilters = search.trim().length > 0 const hasFilters = search.trim().length > 0 || status !== "All"
const sortedBrands = brands
? [...brands].sort((a, b) => {
const cmp =
sortKey === "brandId"
? a.brandId - b.brandId
: sortKey === "createdAt"
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() { function openCreateDialog() {
setEditing(null) setEditing(null)
@@ -176,13 +203,14 @@ export default function BrandsPage() {
aria-label="Search brands" aria-label="Search brands"
/> />
</div> </div>
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="asc" className="text-base">Name (AZ)</SelectItem> <SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="desc" className="text-base">Name (ZA)</SelectItem> <SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -203,7 +231,7 @@ export default function BrandsPage() {
<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">
<Tag className="size-12 text-muted-foreground" /> <Tag className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground"> <p className="text-base text-muted-foreground">
{hasFilters ? "No brands match your search." : "No brands yet."} {hasFilters ? "No brands match your search/filter." : "No brands yet."}
</p> </p>
</div> </div>
)} )}
@@ -213,15 +241,23 @@ export default function BrandsPage() {
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader className="bg-indigo-50">
<TableRow className="hover: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">
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead> <SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead> </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">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{brands.map((b) => ( {sortedBrands!.map((b) => (
<TableRow key={b.brandId}> <TableRow key={b.brandId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell> <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 font-medium">{b.name}</TableCell>
@@ -313,3 +349,35 @@ export default function BrandsPage() {
</div> </div>
) )
} }
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<button
type="button"
className="flex items-center gap-1 hover:text-indigo-900"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
)}
</button>
)
}
@@ -2,13 +2,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } 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 { PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data" import { Category } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
@@ -23,6 +23,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
type SortOrder = "asc" | "desc" type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 5 const PAGE_SIZE = 5
@@ -33,8 +35,12 @@ export default function CategoriesPage() {
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc") const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
// always returns Name ascending, so this doesn't hold across page turns or other columns.
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<Category | null>(null) const [editing, setEditing] = useState<Category | null>(null)
@@ -50,12 +56,12 @@ export default function CategoriesPage() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [search, sortOrder]) }, [search, status])
function load() { function load() {
setError(null) setError(null)
categoriesApi categoriesApi
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE }) .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => { .then((res) => {
setCategories(res.items) setCategories(res.items)
setPagination(res.pagination) setPagination(res.pagination)
@@ -63,9 +69,30 @@ export default function CategoriesPage() {
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
useEffect(load, [search, sortOrder, page]) useEffect(load, [search, status, page])
const hasFilters = search.trim().length > 0 const hasFilters = search.trim().length > 0 || status !== "All"
const sortedCategories = categories
? [...categories].sort((a, b) => {
const cmp =
sortKey === "categoryId"
? a.categoryId - b.categoryId
: sortKey === "createdAt"
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() { function openCreateDialog() {
setEditing(null) setEditing(null)
@@ -175,13 +202,14 @@ export default function CategoriesPage() {
aria-label="Search categories" aria-label="Search categories"
/> />
</div> </div>
<Select<SortOrder> value={sortOrder} onValueChange={(v) => setSortOrder(v ?? "asc")}> <Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="asc" className="text-base">Name (AZ)</SelectItem> <SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="desc" className="text-base">Name (ZA)</SelectItem> <SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -202,7 +230,7 @@ export default function CategoriesPage() {
<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"> <p className="text-base text-muted-foreground">
{hasFilters ? "No categories match your search." : "No categories yet."} {hasFilters ? "No categories match your search/filter." : "No categories yet."}
</p> </p>
</div> </div>
)} )}
@@ -212,15 +240,23 @@ export default function CategoriesPage() {
<Table className="text-base"> <Table className="text-base">
<TableHeader className="bg-indigo-50"> <TableHeader className="bg-indigo-50">
<TableRow className="hover: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">
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead> <SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead> </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">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead> <TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{categories.map((c) => ( {sortedCategories!.map((c) => (
<TableRow key={c.categoryId}> <TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell> <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 font-medium">{c.name}</TableCell>
@@ -320,3 +356,35 @@ export default function CategoriesPage() {
</div> </div>
) )
} }
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<button
type="button"
className="flex items-center gap-1 hover:text-indigo-900"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
)}
</button>
)
}
@@ -11,10 +11,11 @@ import { brandsApi } from "@/lib/api/brands"
import { itemTypesApi } from "@/lib/api/item-types" import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config" import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data" import { validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data" import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -36,26 +37,11 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-") return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
} }
/** /** The item builder only ever offers these two dimensions, regardless of what else exists
* Colour is special-cased by name. This stays a frontend concern: item types are names * in the Item Types master list. */
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9). const BUILDER_ITEM_TYPES = ["color", "size"]
*/ function isBuilderItemType(name: string): boolean {
function isColorCategory(categoryName: string): boolean { return BUILDER_ITEM_TYPES.includes(name.trim().toLowerCase())
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
} }
export default function NewItemPage() { export default function NewItemPage() {
@@ -65,29 +51,27 @@ export default function NewItemPage() {
const [brands, setBrands] = useState<Brand[] | null>(null) const [brands, setBrands] = useState<Brand[] | null>(null)
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null) const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null) const [config, setConfig] = useState<ProductConfig | null>(null)
/** const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
* This form has no Base UOM field by design, so it adopts the first UOM as the base. /** Defaults to the first UOM once loaded; null only means none exist yet. */
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
*/
const [baseUomId, setBaseUomId] = useState<number | null>(null) const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([]) const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null) const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null) const [brandId, setBrandId] = useState<number | null>(null)
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
// isn't sent on submit — nothing to wire it to server-side.
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([]) const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({}) const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({}) const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({}) // Lets a specific generated combination be dropped from the preview table before
// submit, without having to remove and re-add the whole value that produced it.
const [addingCategory, setAddingCategory] = useState(false) const [removedVariantKeys, setRemovedVariantKeys] = useState<Set<string>>(new Set())
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)
@@ -99,14 +83,17 @@ export default function NewItemPage() {
brandsApi.list({ pageSize: 200, status: "Active" }), brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }), itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(), productConfig(),
uomsApi.list({ pageSize: 1 }), uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
]) ])
.then(([cat, br, types, cfg, uoms]) => { .then(([cat, br, types, cfg, uoms, wh]) => {
setCategories(cat.items) setCategories(cat.items)
setBrands(br.items) setBrands(br.items)
setItemTypes(types.items) setItemTypes(types.items)
setConfig(cfg) setConfig(cfg)
setUoms(uoms.items)
setBaseUomId(uoms.items[0]?.uomId ?? null) setBaseUomId(uoms.items[0]?.uomId ?? null)
setWarehouses(wh.items)
}) })
.catch((err) => setLoadError(errorMessage(err))) .catch((err) => setLoadError(errorMessage(err)))
}, []) }, [])
@@ -141,30 +128,8 @@ export default function NewItemPage() {
) )
} }
async function handleAddItemType() { function addValue(itemTypeId: number) {
const nextErrors = validateItemTypeName(newCategoryName) const value = (inputByCategory[itemTypeId] ?? "").trim()
if (nextErrors.name) {
setNewCategoryError(nextErrors.name)
return
}
setAddingCategorySubmitting(true)
try {
const created = await itemTypesApi.create({ name: newCategoryName })
setItemTypes((prev) => [...(prev ?? []), created.data])
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
setNewCategoryName("")
setNewCategoryError(null)
setAddingCategory(false)
toast.success("Item type created", created.data.name)
} catch (err) {
setNewCategoryError(errorMessage(err))
} finally {
setAddingCategorySubmitting(false)
}
}
function addValue(itemTypeId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
if (value) { if (value) {
setValuesByCategory((prev) => { setValuesByCategory((prev) => {
const existing = prev[itemTypeId] ?? [] const existing = prev[itemTypeId] ?? []
@@ -191,7 +156,7 @@ export default function NewItemPage() {
[itemTypes, checkedItemTypeIds, valuesByCategory] [itemTypes, checkedItemTypeIds, valuesByCategory]
) )
const variants = useMemo(() => { const allVariants = useMemo(() => {
if (activeCategories.length === 0) return [] if (activeCategories.length === 0) return []
let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }] let combinations: { key: string; parts: { name: string; value: string }[] }[] = [{ key: "", parts: [] }]
for (const cat of activeCategories) { for (const cat of activeCategories) {
@@ -208,10 +173,19 @@ export default function NewItemPage() {
} }
return combinations.map((c) => ({ return combinations.map((c) => ({
...c, ...c,
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)), sku: buildVariantSku(effectiveLabel, c.parts.map((p) => p.value)),
})) }))
}, [activeCategories, effectiveLabel]) }, [activeCategories, effectiveLabel])
const variants = useMemo(
() => allVariants.filter((v) => !removedVariantKeys.has(v.key)),
[allVariants, removedVariantKeys]
)
function removeVariant(key: string) {
setRemovedVariantKeys((prev) => new Set(prev).add(key))
}
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
@@ -228,7 +202,7 @@ export default function NewItemPage() {
for (const variant of variants) { for (const variant of variants) {
await itemsApi.create({ await itemsApi.create({
sku: variant.sku, sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`, name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map((p) => p.value).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the // Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched // category, which lost the parent entirely. The server rejects a mismatched
// pair with 422. // pair with 422.
@@ -236,7 +210,7 @@ export default function NewItemPage() {
subCategoryId, subCategoryId,
brandId, brandId,
baseUomId, baseUomId,
stockNature: "Stocked", stockNature,
trackingMode: "None", trackingMode: "None",
}) })
created += 1 created += 1
@@ -294,7 +268,11 @@ export default function NewItemPage() {
<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"> <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={handleCategoryChange}> <Select<number | null>
value={categoryId}
onValueChange={handleCategoryChange}
items={(categories ?? []).map((c) => ({ label: c.name, value: c.categoryId }))}
>
<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>
@@ -313,7 +291,12 @@ export default function NewItemPage() {
{config?.subcategoriesEnabled && ( {config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label> <Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}> <Select<number | null>
value={subCategoryId}
onValueChange={setSubCategoryId}
disabled={subCategories.length === 0}
items={subCategories.map((s) => ({ label: s.name, value: s.subCategoryId }))}
>
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} /> <SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger> </SelectTrigger>
@@ -330,7 +313,11 @@ export default function NewItemPage() {
{config?.brandsEnabled && ( {config?.brandsEnabled && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label> <Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}> <Select<number | null>
value={brandId}
onValueChange={setBrandId}
items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))}
>
<SelectTrigger className="h-12! w-full text-base"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" /> <SelectValue placeholder="Select brand" />
</SelectTrigger> </SelectTrigger>
@@ -344,6 +331,57 @@ export default function NewItemPage() {
</Select> </Select>
</div> </div>
)} )}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse (optional)</Label>
<Select<number | null>
value={warehouseId}
onValueChange={setWarehouseId}
items={warehouses.map((w) => ({ label: `${w.code}${w.name}`, value: w.warehouseId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null>
value={baseUomId}
onValueChange={setBaseUomId}
items={uoms.map((u) => ({ label: u.name, value: u.uomId }))}
>
<SelectTrigger className="h-12! w-full text-base">
<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>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
<SelectItem value="Service" className="text-base">Service</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
@@ -358,67 +396,19 @@ export default function NewItemPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{(itemTypes ?? []).map((t) => ( {(itemTypes ?? [])
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50"> .filter((t) => isBuilderItemType(t.name))
<Checkbox .map((t) => (
checked={checkedItemTypeIds.includes(t.itemTypeId)} <label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
onCheckedChange={() => toggleItemType(t.itemTypeId)} <Checkbox
/> checked={checkedItemTypeIds.includes(t.itemTypeId)}
<span className="text-base font-medium">{t.name}</span> onCheckedChange={() => toggleItemType(t.itemTypeId)}
</label> />
))} <span className="text-base font-medium">{t.name}</span>
{!addingCategory && ( </label>
<Button ))}
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another item type"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
</Button>
)}
</div> </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()
handleAddItemType()
}
}}
placeholder="Material"
className="h-11 max-w-xs text-base"
aria-invalid={!!newCategoryError}
autoFocus
/>
<Button type="button" onClick={handleAddItemType} 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]} /> <FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedItemTypeIds.length > 0 && ( {checkedItemTypeIds.length > 0 && (
@@ -426,86 +416,43 @@ export default function NewItemPage() {
{(itemTypes ?? []) {(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId)) .filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => { .map((t) => {
const isColor = isColorCategory(t.name)
const currentInput = inputByCategory[t.itemTypeId] ?? "" const currentInput = inputByCategory[t.itemTypeId] ?? ""
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
function addColor() {
const name = currentColorName.trim()
if (!name) return
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
}
return ( return (
<div key={t.itemTypeId} className="flex flex-col gap-2"> <div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label> <Label className="text-base">{t.name} values</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{isColor ? ( <Input
<> value={currentInput}
<input onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
type="color" onKeyDown={(e) => {
value={currentInput || "#EF4444"} if (e.key === "Enter") {
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} e.preventDefault()
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5" addValue(t.itemTypeId)
aria-label="Pick color" }
/> }}
<Input placeholder={t.name}
value={currentColorName} className="h-11 text-base"
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))} />
onKeyDown={(e) => { <Button type="button" variant="outline" onClick={() => addValue(t.itemTypeId)}>
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, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(t.itemTypeId)
}
}}
placeholder={t.name}
className="h-11 text-base"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
<Plus className="size-4" /> <Plus className="size-4" />
Add {t.name} Add {t.name}
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => { {(valuesByCategory[t.itemTypeId] ?? []).map((v) => (
const decoded = isColor ? decodeColorValue(v) : null <Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
return ( {v}
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm"> <button
{decoded && ( type="button"
<span onClick={() => removeValue(t.itemTypeId, v)}
className="size-3.5 shrink-0 rounded-full border border-black/10" className="rounded-full p-0.5 hover:bg-muted"
style={{ backgroundColor: decoded.hex }} aria-label={`Remove ${v}`}
aria-hidden="true" >
/> <X className="size-3" />
)} </button>
{decoded ? decoded.name : v} </Badge>
<button ))}
type="button"
onClick={() => removeValue(t.itemTypeId, 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> </div>
) )
@@ -526,29 +473,30 @@ export default function NewItemPage() {
Item contract and no initial-receipt flow — stock arrives via a GRN. Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a The input was informational-only under the mock and would now be a
field that silently discards what you type. */} field that silently discards what you type. */}
<TableHead className="h-11 w-8 px-1" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{variants.map((variant) => ( {variants.map((variant) => (
<TableRow key={variant.key}> <TableRow key={variant.key}>
{variant.parts.map((part, i) => { {variant.parts.map((part, i) => (
const decoded = isColorCategory(part.name) ? decodeColorValue(part.value) : null <TableCell key={i} className="px-3 py-2.5">
return ( {part.value}
<TableCell key={i} className="px-3 py-2.5"> </TableCell>
<span className="inline-flex items-center gap-1.5"> ))}
{decoded && ( <TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
<span <TableCell className="py-2.5 pr-3 pl-0">
className="size-3.5 shrink-0 rounded-full border border-black/10" <Button
style={{ backgroundColor: decoded.hex }} type="button"
aria-hidden="true" variant="ghost"
/> size="icon-sm"
)} onClick={() => removeVariant(variant.key)}
{partLabel(part)} aria-label={`Remove ${variant.sku}`}
</span> className="text-destructive hover:bg-destructive/10"
</TableCell> >
) <X className="size-4" />
})} </Button>
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -6,10 +6,12 @@ import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Sear
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 { brandsApi } from "@/lib/api/brands"
import { productConfig } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category, ItemListItem, TrackingMode } from "@/types/master-data" import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -26,6 +28,9 @@ const PAGE_SIZE = 10
export default function ItemsPage() { export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [brands, setBrands] = useState<Brand[]>([])
const [config, setConfig] = useState<ProductConfig | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -64,12 +69,33 @@ export default function ItemsPage() {
useEffect(load, [page, query, status, categoryId, trackingMode]) useEffect(load, [page, query, status, categoryId, trackingMode])
useEffect(() => { useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
brandsApi.list({ pageSize: 200 }).then((res) => setBrands(res.items)).catch(() => {})
productConfig().then(setConfig).catch(() => {})
}, []) }, [])
// No "list all subcategories" endpoint exists — they're scoped per category — so once
// categories are in, fetch each one's subcategories in parallel to build a flat lookup.
useEffect(() => {
if (categories.length === 0) return
Promise.all(
categories.map((c) =>
categoriesApi.listSubCategories(c.categoryId, { pageSize: 200 }).catch(() => ({ items: [] as SubCategory[] }))
)
).then((results) => setSubCategories(results.flatMap((r) => r.items)))
}, [categories])
function categoryName(id: number) { function categoryName(id: number) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
} }
function subCategoryName(id: number) {
return subCategories.find((s) => s.subCategoryId === id)?.name ?? `#${id}`
}
function brandName(id: number) {
return brands.find((b) => b.brandId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
return ( return (
@@ -106,7 +132,11 @@ export default function ItemsPage() {
aria-label="Search items" aria-label="Search items"
/> />
</div> </div>
<Select<number | "All"> value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> <Select<number | "All">
value={categoryId}
onValueChange={(v) => setCategoryId(v ?? "All")}
items={[{ label: "All categories", value: "All" as const }, ...categories.map((c) => ({ label: c.name, value: c.categoryId }))]}
>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base"> <SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All categories" /> <SelectValue placeholder="All categories" />
</SelectTrigger> </SelectTrigger>
@@ -175,6 +205,8 @@ export default function ItemsPage() {
<TableHead className="h-12 px-3 text-sm">SKU</TableHead> <TableHead className="h-12 px-3 text-sm">SKU</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead> <TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead> <TableHead className="h-12 px-3 text-sm">Category</TableHead>
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
<TableHead className="h-12 px-3 text-sm">Type</TableHead> <TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead> <TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead> <TableHead className="h-12 px-3 text-sm">Status</TableHead>
@@ -191,6 +223,16 @@ export default function ItemsPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell> <TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell> <TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
{config?.subcategoriesEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
</TableCell>
)}
{config?.brandsEnabled && (
<TableCell className="px-3 py-3.5 text-muted-foreground">
{item.brandId !== null ? brandName(item.brandId) : "—"}
</TableCell>
)}
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell> <TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell> <TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Info } from "lucide-react" import { ArrowLeft, Package } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config" import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -17,11 +17,10 @@ import { toast } from "@/components/ui/toast"
/** /**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate. * Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
* *
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the * `subcategoriesEnabled`/`brandsEnabled` are the only user-toggleable flags here and are
* server (an item write carrying a gated field gets 422 CONFIG_DISABLED); * enforced by the server (an item write carrying a gated field gets 422 CONFIG_DISABLED).
* `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend * `itemTypesEnabled` has no control on this screen but is still part of the record, so
* hiding the builder's type section IS the enforcement. That distinction is surfaced in * every save round-trips its current value unchanged (the server rejects a partial body).
* the UI rather than hidden, because it changes what "off" actually guarantees.
*/ */
export default function ProductSettingsPage() { export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null) const [config, setConfig] = useState<ProductConfig | null>(null)
@@ -42,7 +41,7 @@ export default function ProductSettingsPage() {
useEffect(load, []) useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) { async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled", next: boolean) {
if (!config) return if (!config) return
setSaving(flag) setSaving(flag)
try { try {
@@ -90,32 +89,29 @@ export default function ProductSettingsPage() {
{!error && config && ( {!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6"> <div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2> <div className="flex items-center gap-2">
<Package className="size-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
</div>
<div className="border-t" />
<ToggleRow <div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
label="Subcategories" <ToggleRow
description="Adds one optional level below a category. Off ⇒ items attach directly to a category." label="Subcategories"
checked={config.subcategoriesEnabled} description="Category hierarchy includes a subcategory level. Off ⇒ products attach directly to a category."
busy={saving === "subcategoriesEnabled"} checked={config.subcategoriesEnabled}
onChange={(v) => toggle("subcategoriesEnabled", v)} busy={saving === "subcategoriesEnabled"}
/> onChange={(v) => toggle("subcategoriesEnabled", v)}
/>
<ToggleRow <ToggleRow
label="Brands" label="Brands"
description="Items may carry a brand." description="Products may carry a brand."
checked={config.brandsEnabled} checked={config.brandsEnabled}
busy={saving === "brandsEnabled"} busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)} onChange={(v) => toggle("brandsEnabled", v)}
/> />
</div>
<ToggleRow
label="Item types"
description="The item builder offers Color / Size / Material dimensions when creating items."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything."
/>
{config.updatedAt && ( {config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground"> <p className="pt-2 text-sm text-muted-foreground">
@@ -132,7 +128,6 @@ export default function ProductSettingsPage() {
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
subcategoriesEnabled: "Subcategories", subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands", brandsEnabled: "Brands",
itemTypesEnabled: "Item types",
} }
function ToggleRow({ function ToggleRow({
@@ -141,28 +136,26 @@ function ToggleRow({
checked, checked,
busy, busy,
onChange, onChange,
note,
}: { }: {
label: string label: string
description: string description: string
checked: boolean checked: boolean
busy: boolean busy: boolean
onChange: (next: boolean) => void onChange: (next: boolean) => void
note?: string
}) { }) {
return ( return (
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0"> <div className="flex items-start gap-4 py-4">
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={busy}
aria-label={label}
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{label}</span> <span className="text-base font-medium text-foreground">{label}</span>
<span className="text-sm text-muted-foreground">{description}</span> <span className="text-sm text-muted-foreground">{description}</span>
{note && (
<span className="mt-1 inline-flex items-start gap-1.5 text-sm text-amber-700">
<Info className="mt-0.5 size-4 shrink-0" />
{note}
</span>
)}
</div> </div>
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
</div> </div>
) )
} }
@@ -19,7 +19,6 @@ import {
ShieldCheck, ShieldCheck,
ShoppingCart, ShoppingCart,
SlidersHorizontal, SlidersHorizontal,
SwatchBook,
Tag, Tag,
Truck, Truck,
Users, Users,
@@ -53,9 +52,7 @@ const navItems: {
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes }, { title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree }, { title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag }, { title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler }, { title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
], ],
}, },
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
@@ -73,6 +70,7 @@ const navItems: {
children: [ children: [
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck }, { title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users }, { title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
], ],
}, },
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle }, { title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },