Complete all for Items
This commit is contained in:
@@ -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>
|
||||
|
||||
+77
-57
@@ -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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user