Complete all for Items

This commit is contained in:
2026-07-17 14:27:51 +05:30
parent f72b24fcaa
commit 62a5d857de
103 changed files with 2540 additions and 3259 deletions
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
setVendorId(rfqVendorId)
setLines(
rfq.lines.map((l): DraftLine => {
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
const cell = comparison.rows
.find((row) => row.itemId === l.itemId)
?.quotes.find((q) => q.vendorId === rfqVendorId)
return {
key: newKey(),
itemId: l.itemId,
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
const quotedVendorIds = useMemo(() => {
const set = new Set<number>()
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId)
return set
}, [comparison])
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
/**
* Vendors still available to quote.
*
* This used to be "invited but not yet quoted", but the invited list does not survive:
* `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor
* link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the
* comparison's columns come from who actually quoted rather than who was asked.
*/
const pendingVendors = useMemo(
() => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId),
[vendors, quotedVendorIds],
)
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -155,9 +166,9 @@ export default function RfqDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
<RfqStatusBadge status={rfq.status} />
</div>
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
<p className="text-base text-muted-foreground">
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
</p>
</div>
</div>
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
{comparison.lines.every((l) => l.cells.length === 0) ? (
{comparison.rows.every((r) => r.quotes.length === 0) ? (
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
) : (
<div className="overflow-x-auto">
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
{rfq.vendorIds.map((vid) => (
{/* Columns are the vendors that actually quoted — the server computes this. */}
{comparison.vendorIds.map((vid) => (
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{comparison.lines.map((line) => {
{comparison.rows.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
{rfq.vendorIds.map((vid) => {
const cell = line.cells.find((c) => c.vendorId === vid)
{comparison.vendorIds.map((vid) => {
const cell = line.quotes.find((c) => c.vendorId === vid)
return (
<TableCell key={vid} className="px-3 py-3.5">
{cell ? (
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
<Label className="text-base">Vendor</Label>
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select an invited vendor" />
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{pendingVendors.map((vid) => (
@@ -99,6 +99,12 @@ function NewRfqContent() {
setHeaderError(null)
setSubmitError(null)
// The server requires a requisition — an RFQ is always raised against one
// (docs/11 §3.2). Catch it here rather than letting the POST 400.
if (requisitionId === null) {
setHeaderError("Select the requisition this RFQ is raised against.")
return
}
if (vendorIds.size === 0) {
setHeaderError("Select at least one vendor to invite.")
return
@@ -5,10 +5,8 @@ import Link from "next/link"
import { FileText, Plus } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { RfqSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
useEffect(() => {
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([r, v]) => {
setRfqs(r.items)
setVendors(v.items)
})
rfqsApi
.list()
.then((r) => setRfqs(r.items))
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorNames(vendorIds: number[]) {
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
{/* "Vendors invited" is gone: the invite list is validated on create but not
persisted (docs/11 §3.2). Quotations received is the fact that survives. */}
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Quotations</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.quotationCount}</TableCell>
<TableCell className="px-3 py-3.5">
<RfqStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
setSubCategoryId(data.subCategoryId)
setBrandId(data.brandId)
setBaseUomId(data.baseUomId)
setDefaultVendorId(data.defaultVendorId)
setItemType(data.itemType)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "")
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
@@ -139,7 +145,7 @@ export default function ItemDetailPage() {
try {
const result = await itemsApi.update(
item.itemId,
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
{ sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null },
etag
)
applyItem(result.data)
@@ -389,8 +395,10 @@ export default function ItemDetailPage() {
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
{/* "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). */}
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function BrandsPage() {
function load() {
setError(null)
brandsApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
@@ -87,10 +88,16 @@ export default function BrandsPage() {
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)
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
} else {
result = await brandsApi.create({ name })
}
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +111,16 @@ export default function BrandsPage() {
}
}
async function handleDelete(brand: Brand) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setDeletingId(brand.brandId)
try {
await brandsApi.remove(brand.brandId)
toast.success("Brand deleted", brand.name)
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not delete brand", errorMessage(err))
toast.error("Could not update brand status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +215,7 @@ export default function BrandsPage() {
<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">Status</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>
@@ -215,6 +225,9 @@ export default function BrandsPage() {
<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">
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
</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">
@@ -227,26 +240,32 @@ export default function BrandsPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${b.name}`}
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
<Trash2 className="size-4" />
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${b.name}?`}
description="This permanently removes the brand."
confirmLabel="Delete"
onConfirm={() => handleDelete(b)}
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</AlertDialog>
</div>
@@ -0,0 +1,233 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Subcategories of one category — the single optional level below it (FR-MD-04).
* The hierarchy is exactly two deep, so there is no recursion here by design.
*/
export default function CategorySubCategoriesPage() {
const params = useParams<{ id: string }>()
const categoryId = Number(params.id)
const [category, setCategory] = useState<Category | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<SubCategory | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
categoriesApi
.get(categoryId)
.then((res) => setCategory(res.data))
.catch((err) => setError(errorMessage(err)))
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => setSubCategories(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [categoryId])
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(sub: SubCategory) {
setEditing(sub)
setName(sub.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await subCategoriesApi.get(editing.subCategoryId)
await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "")
} else {
await categoriesApi.createSubCategory(categoryId, { name })
}
toast.success(editing ? "Subcategory updated" : "Subcategory created", name)
setOpen(false)
setName("")
setEditing(null)
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleToggleStatus(sub: SubCategory) {
const next = sub.status === "Active" ? "Inactive" : "Active"
setTogglingId(sub.subCategoryId)
try {
await subCategoriesApi.updateStatus(sub.subCategoryId, next)
toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(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/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground">
The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Subcategory</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit subcategory" : "New subcategory"}</DialogTitle>
<DialogDescription>Give the subcategory a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="sub-name">Name</FieldLabel>
<Input id="sub-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Hex Bolts" 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 && subCategories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && subCategories !== null && subCategories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Network className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No subcategories yet items can attach straight to the category.</p>
</div>
)}
{!error && subCategories !== null && subCategories.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">Status</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>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.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 ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
setSubmitting(true)
try {
const category = editing
? await categoriesApi.update(editing.categoryId, { name })
: await categoriesApi.create({ name })
toast.success(editing ? "Category updated" : "Category created", category.name)
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +110,16 @@ export default function CategoriesPage() {
}
}
async function handleDelete(category: Category) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.remove(category.categoryId)
toast.success("Category deleted", category.name)
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
<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">Status</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>
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
<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">
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
</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">
{/* Subcategories are their own resource now, not a nested tree. */}
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
<Button
variant="ghost"
size="icon-sm"
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
<Trash2 className="size-4" />
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
@@ -2,15 +2,16 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName } from "@/lib/validations/master-data"
import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { VariantCategory } from "@/types/master-data"
import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -19,22 +20,30 @@ 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)
/**
* Item Types (docs/11 §2.7) the dimension names (Color, Size, Material) the item
* builder's checkboxes read. Formerly "Variant Categories" in this app.
*
* These are names only. The values (Red, S, M) live in each item's generated SKU and are
* not stored, so nothing here links to an item renaming a type leaves existing SKUs
* untouched.
*/
export default function ItemTypesPage() {
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<VariantCategory | null>(null)
const [editing, setEditing] = useState<ItemType | 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)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
variantCategoriesApi
.list()
.then((res) => setCategories(res.items))
itemTypesApi
.list({ pageSize: 200 })
.then((res) => setItemTypes(res.items))
.catch((err) => setError(errorMessage(err)))
}
@@ -47,24 +56,28 @@ export default function VariantsPage() {
setOpen(true)
}
function openEditDialog(category: VariantCategory) {
setEditing(category)
setName(category.name)
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateVariantCategoryName(name)
const nextErrors = validateItemTypeName(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)
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setEditing(null)
@@ -72,22 +85,24 @@ export default function VariantsPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete(category: VariantCategory) {
setDeletingId(category.variantCategoryId)
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(itemType: ItemType) {
const next = itemType.status === "Active" ? "Inactive" : "Active"
setTogglingId(itemType.itemTypeId)
try {
await variantCategoriesApi.remove(category.variantCategoryId)
toast.success("Variant category deleted", category.name)
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update status", errorMessage(err))
} finally {
setDeletingId(null)
setTogglingId(null)
}
}
@@ -99,23 +114,25 @@ export default function VariantsPage() {
<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>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Category</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</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>
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
<Input
id="variant-category-name"
id="item-type-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Material"
@@ -140,7 +157,7 @@ export default function VariantsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
{!error && itemTypes === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -148,37 +165,36 @@ export default function VariantsPage() {
</div>
)}
{!error && categories !== null && categories.length === 0 && (
{!error && itemTypes !== null && itemTypes.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>
<p className="text-base text-muted-foreground">No item types yet.</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
{!error && itemTypes !== null && itemTypes.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">Status</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>
{itemTypes.map((t) => (
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.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)}
>
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
<Pencil className="size-4" />
</Button>
@@ -188,20 +204,24 @@ export default function VariantsPage() {
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.variantCategoryId}
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
disabled={togglingId === t.itemTypeId}
/>
}
>
<Trash2 className="size-4" />
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the variant category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={t.status === "Active" ? "destructive" : "success"}
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
description={
t.status === "Active"
? "It disappears from the item builder. Existing items keep their SKUs — nothing references this record."
: "It reappears in the item builder."
}
confirmLabel={t.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(t)}
/>
</AlertDialog>
</div>
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, VariantCategory } from "@/types/master-data"
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
}
/**
* Colour is special-cased by name. This stays a frontend concern: item types are names
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
*/
function isColorCategory(categoryName: string): boolean {
return categoryName.trim().toLowerCase() === "color"
}
@@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string {
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
}
// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data).
const DEFAULT_BASE_UOM_ID = 1
export default function NewItemPage() {
const router = useRouter()
const [categories, setCategories] = useState<Category[] | null>(null)
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | null>(null)
const [brands, setBrands] = useState<Brand[] | null>(null)
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
/**
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
* 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 [loadError, setLoadError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState<number[]>([])
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
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("")
@@ -83,23 +94,40 @@ export default function NewItemPage() {
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
.then(([cat, br, vc]) => {
Promise.all([
categoriesApi.list({ pageSize: 200, status: "Active" }),
brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(),
uomsApi.list({ pageSize: 1 }),
])
.then(([cat, br, types, cfg, uoms]) => {
setCategories(cat.items)
setBrands(br.items)
setVariantCategories(vc.items)
setItemTypes(types.items)
setConfig(cfg)
setBaseUomId(uoms.items[0]?.uomId ?? null)
})
.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 ?? ""
// Subcategories are their own resource now — fetched per category rather than filtered
// out of a flat list by parentId (that column no longer exists).
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) {
setSubCategories([])
return
}
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategories(res.items))
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
function handleCategoryChange(value: number | null) {
@@ -107,28 +135,27 @@ export default function NewItemPage() {
setSubCategoryId(null)
}
function toggleVariantCategory(variantCategoryId: number) {
setCheckedVariantCategoryIds((prev) =>
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
function toggleItemType(itemTypeId: number) {
setCheckedItemTypeIds((prev) =>
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
)
setQuantities({})
}
async function handleAddVariantCategory() {
const nextErrors = validateVariantCategoryName(newCategoryName)
async function handleAddItemType() {
const nextErrors = validateItemTypeName(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])
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("Variant category created", category.name)
toast.success("Item type created", created.data.name)
} catch (err) {
setNewCategoryError(errorMessage(err))
} finally {
@@ -136,34 +163,32 @@ export default function NewItemPage() {
}
}
function addValue(variantCategoryId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
function addValue(itemTypeId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
if (value) {
setValuesByCategory((prev) => {
const existing = prev[variantCategoryId] ?? []
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [variantCategoryId]: [...existing, value] }
return { ...prev, [itemTypeId]: [...existing, value] }
})
setQuantities({})
}
setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" }))
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(variantCategoryId: number, value: string) {
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
[itemTypeId]: (prev[itemTypeId] ?? []).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]
(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
.filter((t) => t.values.length > 0),
[itemTypes, checkedItemTypeIds, valuesByCategory]
)
const variants = useMemo(() => {
@@ -183,44 +208,58 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
}))
}, [activeCategories, effectiveCategoryLabel])
}, [activeCategories, effectiveLabel])
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
return
}
setSubmitting(true)
let created = 0
try {
let created = 0
for (const variant of variants) {
const qty = Number(quantities[variant.key] || 0)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
categoryId: effectiveCategoryId as number,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
categoryId: categoryId as number,
subCategoryId,
brandId,
baseUomId: DEFAULT_BASE_UOM_ID,
itemType: "Stocked",
baseUomId,
stockNature: "Stocked",
trackingMode: "None",
initialQty: Number.isFinite(qty) ? qty : 0,
})
created += 1
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
router.push("/dashboard/products")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create variants", errorMessage(err))
// Each row is its own POST with no transaction, so a failure partway (e.g. a
// duplicate SKU) leaves the earlier rows created. Say so rather than implying
// nothing happened.
const detail = errorMessage(err)
setSubmitError(
created > 0
? `${detail}${created} item${created === 1 ? "" : "s"} were already created before this failed.`
: detail,
)
toast.error("Could not create all variants", detail)
} finally {
setSubmitting(false)
}
}
const loading = !categories || !brands || !variantCategories
const loading = !categories || !brands || !itemTypes || !config
return (
<div className="flex flex-col gap-6">
@@ -230,7 +269,7 @@ export default function NewItemPage() {
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and variant categories (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
</div>
</div>
@@ -240,6 +279,16 @@ export default function NewItemPage() {
{loading && !loadError && <Skeleton className="h-64 w-full" />}
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM {" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
create one first
</Link>
.
</div>
)}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -250,7 +299,7 @@ export default function NewItemPage() {
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{topCategories.map((c) => (
{(categories ?? []).map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
@@ -259,54 +308,63 @@ export default function NewItemPage() {
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategoryOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Config flags are honoured by hiding the field: sending a gated value would
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategories.map((s) => (
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variants</h2>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the variant categories that apply, then add their values to generate a SKU per combination.
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{(variantCategories ?? []).map((vc) => (
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => (
<label key={t.itemTypeId} 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)}
checked={checkedItemTypeIds.includes(t.itemTypeId)}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{vc.name}</span>
<span className="text-base font-medium">{t.name}</span>
</label>
))}
{!addingCategory && (
@@ -314,7 +372,7 @@ export default function NewItemPage() {
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another variant category"
aria-label="Add another item type"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
@@ -331,7 +389,7 @@ export default function NewItemPage() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddVariantCategory()
handleAddItemType()
}
}}
placeholder="Material"
@@ -339,7 +397,7 @@ export default function NewItemPage() {
aria-invalid={!!newCategoryError}
autoFocus
/>
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
<Plus className="size-4" />
Add
</Button>
@@ -363,38 +421,38 @@ export default function NewItemPage() {
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedVariantCategoryIds.length > 0 && (
{checkedItemTypeIds.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] ?? ""
{(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => {
const isColor = isColorCategory(t.name)
const currentInput = inputByCategory[t.itemTypeId] ?? ""
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
function addColor() {
const name = currentColorName.trim()
if (!name) return
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
}
return (
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
<Label className="text-base">{vc.name} values</Label>
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.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 }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: 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 }))}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
@@ -408,24 +466,24 @@ export default function NewItemPage() {
) : (
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(vc.variantCategoryId)
addValue(t.itemTypeId)
}
}}
placeholder={vc.name}
placeholder={t.name}
className="h-11 text-base"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(vc.variantCategoryId))}>
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
<Plus className="size-4" />
Add {vc.name}
Add {t.name}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
const decoded = isColor ? decodeColorValue(v) : null
return (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
@@ -439,7 +497,7 @@ export default function NewItemPage() {
{decoded ? decoded.name : v}
<button
type="button"
onClick={() => removeValue(vc.variantCategoryId, v)}
onClick={() => removeValue(t.itemTypeId, v)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${decoded ? decoded.name : v}`}
>
@@ -461,10 +519,13 @@ export default function NewItemPage() {
<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 key={cat.itemTypeId} 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>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
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
field that silently discards what you type. */}
</TableRow>
</TableHeader>
<TableBody>
@@ -488,16 +549,6 @@ export default function NewItemPage() {
)
})}
<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>
@@ -505,6 +556,7 @@ export default function NewItemPage() {
</div>
)}
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
@@ -191,7 +191,7 @@ export default function ItemsPage() {
</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">{item.itemType}</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">
<Badge
@@ -0,0 +1,168 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Info } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
*
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are 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
* hiding the builder's type section IS the enforcement. That distinction is surfaced in
* the UI rather than hidden, because it changes what "off" actually guarantees.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<keyof ProductConfig | null>(null)
function load() {
setError(null)
productConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
[flag]: next,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
} finally {
setSaving(null)
}
}
return (
<div className="flex flex-col gap-6">
<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">Product Configuration</h1>
<p className="text-base text-muted-foreground">
Switch optional product features on or off for this deployment (FR-MD-11).
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
<ToggleRow
label="Subcategories"
description="Adds one optional level below a category. Off ⇒ items attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
/>
<ToggleRow
label="Brands"
description="Items may carry a brand."
checked={config.brandsEnabled}
busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)}
/>
<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 && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
const LABELS: Record<string, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Item types",
}
function ToggleRow({
label,
description,
checked,
busy,
onChange,
note,
}: {
label: string
description: string
checked: boolean
busy: boolean
onChange: (next: boolean) => void
note?: string
}) {
return (
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0">
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{label}</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>
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
</div>
)
}
@@ -1,444 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn"
import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
binId: number | null
qty: string
unitCost: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `egline-${keySeq}`
}
function emptyLine(): DraftLine {
return {
key: newKey(),
poLineId: null,
itemId: null,
uomId: null,
binId: null,
qty: "",
unitCost: "",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
}
}
export default function EditGrnPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([])
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!Number.isFinite(grnId)) return
Promise.all([
grnsApi.get(grnId),
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
])
.then(([g, wh, it, uo]) => {
if (g.status !== "Draft") {
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
setGrn(g)
return
}
setGrn(g)
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setWarehouseId(g.warehouseId)
setLines(
g.lines.map(
(l): DraftLine => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: l.binId,
qty: String(l.qty),
unitCost: String(l.unitCost),
holdStatus: l.holdStatus,
batchNo: "",
expiryDate: "",
serialNumbersText: "",
})
)
)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [grnId])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key))
}
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
if (!grn) return
setSubmitError(null)
setHeaderError(null)
if (!warehouseId) {
setHeaderError("Select a warehouse.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const errors = validateLine({
itemId: line.itemId,
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
uomId: l.uomId as number,
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
}
})
setSubmitting(true)
try {
const updated = await grnsApi.update(grn.grnId, {
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN updated", `${updated.docNo} saved.`)
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not update GRN", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (loadError) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Edit GRN</h1>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
</div>
)
}
const loading = !grn || !warehouses || !items || !uoms
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {grn?.docNo ?? "GRN"}</h1>
<p className="text-base text-muted-foreground">Only Draft GRNs can be edited confirming posts stock layers permanently.</p>
</div>
</div>
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<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>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
{lines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link
href={`/dashboard/receiving/grn/${grnId}`}
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Save changes"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -50,7 +50,7 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!grn) return
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
}, [grn?.warehouseId])
function itemFor(itemId: number) {
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
function switchMode(next: Mode) {
@@ -2,14 +2,13 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,7 +22,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -40,7 +38,6 @@ export default function GrnListPage() {
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
// Debounce the search box so typing doesn't refetch on every keystroke.
useEffect(() => {
@@ -71,19 +68,6 @@ export default function GrnListPage() {
useEffect(load, [page, query, status])
async function handleDelete(grn: GrnSummary) {
setDeletingId(grn.grnId)
try {
await grnsApi.remove(grn.grnId)
toast.success("GRN deleted", `${grn.docNo} has been removed.`)
load()
} catch (err) {
toast.error("Could not delete GRN", errorMessage(err))
} finally {
setDeletingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
return (
@@ -196,7 +180,6 @@ export default function GrnListPage() {
</TableHeader>
<TableBody>
{grns.map((grn) => {
const isDraft = grn.status === "Draft"
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
@@ -224,47 +207,9 @@ export default function GrnListPage() {
<Eye className="size-4" />
</Link>
{isDraft ? (
<Link
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${grn.docNo}`}
>
<Pencil className="size-4" />
</Link>
) : (
<Button
variant="ghost"
size="icon-sm"
disabled
aria-label={`Edit ${grn.docNo} (not editable once ${grn.status.toLowerCase()})`}
>
<Pencil className="size-4" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${grn.docNo}`}
disabled={!isDraft || deletingId === grn.grnId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${grn.docNo}?`}
description="This permanently removes the draft GRN. It has not been confirmed, so no stock layers or ledger entries exist yet."
confirmLabel="Delete"
onConfirm={() => handleDelete(grn)}
/>
</AlertDialog>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
useEffect(() => {
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([r, it, wh]) => {
setRows(r)
setRows(r.items)
setItems(it.items)
setWarehouses(wh.items)
})
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
const key = `${alert.itemId}-${alert.warehouseId}`
setRequesting(key)
try {
// The server returns the full requisition; the suggested qty is on its line.
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
setRequested((prev) => new Set(prev).add(key))
toast.success("Requisition created", `${res.docNo} for ${res.qty} units.`)
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
setSrcBins([])
return
}
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
}, [srcWarehouseId])
useEffect(() => {
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
setDestBins([])
return
}
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
}, [destWarehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
import { wastageApi, wastageReasonCodeIds } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setWarehouses(wh.items)
setItems(it.items)
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
async function handleSubmit() {
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
import { wastageApi, wastageReasonCodeIds, WastageRecord } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -33,8 +33,7 @@ export default function WastagePage() {
useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([rc, it, wh]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
setItems(it.items)
setWarehouses(wh.items)
})
@@ -40,7 +40,7 @@ export default function WarehouseDetailPage() {
const [submitting, setSubmitting] = useState(false)
function loadBins() {
warehousesApi.listBins(warehouseId).then((res) => setBins(res.items)).catch((err) => setError(errorMessage(err)))
warehousesApi.listBins(warehouseId).then(setBins).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
@@ -41,7 +41,7 @@ export default function WarehousesPage() {
.then(async (res) => {
setWarehouses(res.items)
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
setBins(allBins.flatMap((b) => b.items))
setBins(allBins.flat())
})
.catch((err) => setError(errorMessage(err)))
}