Complete all for Items

This commit is contained in:
2026-07-17 14:27:51 +05:30
parent f72b24fcaa
commit 62a5d857de
103 changed files with 2540 additions and 3259 deletions
+7
View File
@@ -0,0 +1,7 @@
# Origin of the ERPCore backend. Used ONLY by the Next rewrite proxy in next.config.ts
# (server-side), so it is intentionally not NEXT_PUBLIC_* — the browser never sees it and
# only ever calls this Next server at same-origin /api/v1.
#
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse.
BACKEND_ORIGIN=http://localhost:5224
+2
View File
@@ -32,6 +32,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# ...except the template, which carries no secrets and documents what to set.
!.env.local.example
# vercel
.vercel
@@ -115,7 +115,9 @@ function NewPurchaseOrderContent() {
setVendorId(rfqVendorId)
setLines(
rfq.lines.map((l): DraftLine => {
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
const cell = comparison.rows
.find((row) => row.itemId === l.itemId)
?.quotes.find((q) => q.vendorId === rfqVendorId)
return {
key: newKey(),
itemId: l.itemId,
@@ -69,11 +69,22 @@ export default function RfqDetailPage() {
const quotedVendorIds = useMemo(() => {
const set = new Set<number>()
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
for (const row of comparison?.rows ?? []) for (const quote of row.quotes) set.add(quote.vendorId)
return set
}, [comparison])
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
/**
* Vendors still available to quote.
*
* This used to be "invited but not yet quoted", but the invited list does not survive:
* `POST /rfqs` validates `vendorIds` and then discards them — there is no RFQ↔vendor
* link in the model (docs/11 §3.2). So any active vendor may be quoted here, and the
* comparison's columns come from who actually quoted rather than who was asked.
*/
const pendingVendors = useMemo(
() => vendors.filter((v) => v.status === "Active" && !quotedVendorIds.has(v.vendorId)).map((v) => v.vendorId),
[vendors, quotedVendorIds],
)
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
@@ -155,9 +166,9 @@ export default function RfqDetailPage() {
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
<RfqStatusBadge status={rfq.status} />
</div>
{/* No "Invited: …" — the invited-vendor list is not persisted (docs/11 §3.2). */}
<p className="text-base text-muted-foreground">
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
</p>
</div>
</div>
@@ -191,7 +202,7 @@ export default function RfqDetailPage() {
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Vendor comparison</h2>
{comparison.lines.every((l) => l.cells.length === 0) ? (
{comparison.rows.every((r) => r.quotes.length === 0) ? (
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
) : (
<div className="overflow-x-auto">
@@ -199,19 +210,20 @@ export default function RfqDetailPage() {
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
{rfq.vendorIds.map((vid) => (
{/* Columns are the vendors that actually quoted — the server computes this. */}
{comparison.vendorIds.map((vid) => (
<TableHead key={vid} className="h-12 px-3 text-sm">{vendorFor(vid)?.code ?? `#${vid}`}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{comparison.lines.map((line) => {
{comparison.rows.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.itemId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
{rfq.vendorIds.map((vid) => {
const cell = line.cells.find((c) => c.vendorId === vid)
{comparison.vendorIds.map((vid) => {
const cell = line.quotes.find((c) => c.vendorId === vid)
return (
<TableCell key={vid} className="px-3 py-3.5">
{cell ? (
@@ -256,7 +268,7 @@ export default function RfqDetailPage() {
<Label className="text-base">Vendor</Label>
<Select<number | null> value={quoteVendorId} onValueChange={selectQuoteVendor}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select an invited vendor" />
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{pendingVendors.map((vid) => (
@@ -99,6 +99,12 @@ function NewRfqContent() {
setHeaderError(null)
setSubmitError(null)
// The server requires a requisition — an RFQ is always raised against one
// (docs/11 §3.2). Catch it here rather than letting the POST 400.
if (requisitionId === null) {
setHeaderError("Select the requisition this RFQ is raised against.")
return
}
if (vendorIds.size === 0) {
setHeaderError("Select at least one vendor to invite.")
return
@@ -5,10 +5,8 @@ import Link from "next/link"
import { FileText, Plus } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { RfqSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -17,22 +15,17 @@ import { RfqStatusBadge } from "@/components/procurement/status-badges"
export default function RfqsListPage() {
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
// list is not persisted (docs/11 §3.2), so there is nothing to resolve names for.
useEffect(() => {
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([r, v]) => {
setRfqs(r.items)
setVendors(v.items)
})
rfqsApi
.list()
.then((r) => setRfqs(r.items))
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorNames(vendorIds: number[]) {
return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ")
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -75,9 +68,11 @@ export default function RfqsListPage() {
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Requisition</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendors invited</TableHead>
{/* "Vendors invited" is gone: the invite list is validated on create but not
persisted (docs/11 §3.2). Quotations received is the fact that survives. */}
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Quotations</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -89,11 +84,11 @@ export default function RfqsListPage() {
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground"></span>}</TableCell>
<TableCell className="px-3 py-3.5">{vendorNames(r.vendorIds)}</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.quotationCount}</TableCell>
<TableCell className="px-3 py-3.5">
<RfqStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
@@ -13,7 +13,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data"
import { Item, ItemReorderSetting, StockNature, TrackingMode, UomConversion } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -63,9 +63,13 @@ export default function ItemDetailPage() {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
// Carried through edits so a save doesn't silently drop the item's subcategory/brand.
// Not editable here — they are chosen on the create screen's builder.
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [stockNature, setStockNature] = useState<StockNature>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
@@ -93,9 +97,11 @@ export default function ItemDetailPage() {
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
setSubCategoryId(data.subCategoryId)
setBrandId(data.brandId)
setBaseUomId(data.baseUomId)
setDefaultVendorId(data.defaultVendorId)
setItemType(data.itemType)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setTaxClass(data.taxClass ?? "")
setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) })))
@@ -139,7 +145,7 @@ export default function ItemDetailPage() {
try {
const result = await itemsApi.update(
item.itemId,
{ sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null },
{ sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null },
etag
)
applyItem(result.data)
@@ -389,8 +395,10 @@ export default function ItemDetailPage() {
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
{/* "Item type" now means a Color/Size dimension master — this field is the
stock-nature one it used to be confused with (docs/11 §8). */}
<Label className="text-base">Stock nature</Label>
<Select<StockNature> value={stockNature} onValueChange={(v) => v && setStockNature(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function BrandsPage() {
function load() {
setError(null)
brandsApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setBrands(res.items)
setPagination(res.pagination)
@@ -87,10 +88,16 @@ export default function BrandsPage() {
setSubmitting(true)
try {
const brand = editing
? await brandsApi.update(editing.brandId, { name })
: await brandsApi.create({ name })
toast.success(editing ? "Brand updated" : "Brand created", brand.name)
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await brandsApi.get(editing.brandId)
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
} else {
result = await brandsApi.create({ name })
}
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +111,16 @@ export default function BrandsPage() {
}
}
async function handleDelete(brand: Brand) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active"
setDeletingId(brand.brandId)
try {
await brandsApi.remove(brand.brandId)
toast.success("Brand deleted", brand.name)
await brandsApi.updateStatus(brand.brandId, next)
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
load()
} catch (err) {
toast.error("Could not delete brand", errorMessage(err))
toast.error("Could not update brand status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +215,7 @@ export default function BrandsPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,6 +225,9 @@ export default function BrandsPage() {
<TableRow key={b.brandId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
@@ -227,26 +240,32 @@ export default function BrandsPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by transactions must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${b.name}`}
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
disabled={deletingId === b.brandId}
/>
}
>
<Trash2 className="size-4" />
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${b.name}?`}
description="This permanently removes the brand."
confirmLabel="Delete"
onConfirm={() => handleDelete(b)}
variant={b.status === "Active" ? "destructive" : "success"}
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
description={
b.status === "Active"
? "The brand stays on existing items but cannot be assigned to new ones."
: "The brand becomes selectable again."
}
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(b)}
/>
</AlertDialog>
</div>
@@ -0,0 +1,233 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/**
* Subcategories of one category — the single optional level below it (FR-MD-04).
* The hierarchy is exactly two deep, so there is no recursion here by design.
*/
export default function CategorySubCategoriesPage() {
const params = useParams<{ id: string }>()
const categoryId = Number(params.id)
const [category, setCategory] = useState<Category | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<SubCategory | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
categoriesApi
.get(categoryId)
.then((res) => setCategory(res.data))
.catch((err) => setError(errorMessage(err)))
categoriesApi
.listSubCategories(categoryId, { pageSize: 200 })
.then((res) => setSubCategories(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [categoryId])
function openCreateDialog() {
setEditing(null)
setName("")
setErrors({})
setOpen(true)
}
function openEditDialog(sub: SubCategory) {
setEditing(sub)
setName(sub.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await subCategoriesApi.get(editing.subCategoryId)
await subCategoriesApi.update(editing.subCategoryId, { name }, current.etag ?? "")
} else {
await categoriesApi.createSubCategory(categoryId, { name })
}
toast.success(editing ? "Subcategory updated" : "Subcategory created", name)
setOpen(false)
setName("")
setEditing(null)
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update subcategory" : "Could not create subcategory", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleToggleStatus(sub: SubCategory) {
const next = sub.status === "Active" ? "Inactive" : "Active"
setTogglingId(sub.subCategoryId)
try {
await subCategoriesApi.updateStatus(sub.subCategoryId, next)
toast.success(next === "Inactive" ? "Subcategory deactivated" : "Subcategory activated", sub.name)
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground">
The one optional level below a category (FR-MD-04). A subcategory cannot be moved to another category.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Subcategory</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit subcategory" : "New subcategory"}</DialogTitle>
<DialogDescription>Give the subcategory a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="sub-name">Name</FieldLabel>
<Input id="sub-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Hex Bolts" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
<div className="flex justify-center gap-3 pt-2">
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && subCategories === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && subCategories !== null && subCategories.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Network className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No subcategories yet items can attach straight to the category.</p>
</div>
)}
{!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subCategories.map((s) => (
<TableRow key={s.subCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={s.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}`}
disabled={togglingId === s.subCategoryId}
/>
}
>
{s.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={s.status === "Active" ? "destructive" : "success"}
title={`${s.status === "Active" ? "Deactivate" : "Activate"} ${s.name}?`}
description={
s.status === "Active"
? "It stays on existing items but cannot be assigned to new ones."
: "It becomes selectable again."
}
confirmLabel={s.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(s)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ListTree, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
@@ -12,6 +12,7 @@ import { PaginationMeta } from "@/types/common"
import { Category } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -54,7 +55,7 @@ export default function CategoriesPage() {
function load() {
setError(null)
categoriesApi
.list({ q: search || undefined, sortOrder, page, pageSize: PAGE_SIZE })
.list({ q: search || undefined, sort: sortOrder === "desc" ? "-name" : "name", page, pageSize: PAGE_SIZE })
.then((res) => {
setCategories(res.items)
setPagination(res.pagination)
@@ -87,10 +88,15 @@ export default function CategoriesPage() {
setSubmitting(true)
try {
const category = editing
? await categoriesApi.update(editing.categoryId, { name })
: await categoriesApi.create({ name })
toast.success(editing ? "Category updated" : "Category created", category.name)
let result
if (editing) {
// The list carries no ETag, so re-read for a fresh If-Match rather than guessing.
const current = await categoriesApi.get(editing.categoryId)
result = await categoriesApi.update(editing.categoryId, { name }, current.etag ?? "")
} else {
result = await categoriesApi.create({ name })
}
toast.success(editing ? "Category updated" : "Category created", result.data.name)
setOpen(false)
setName("")
setEditing(null)
@@ -104,14 +110,16 @@ export default function CategoriesPage() {
}
}
async function handleDelete(category: Category) {
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(category: Category) {
const next = category.status === "Active" ? "Inactive" : "Active"
setDeletingId(category.categoryId)
try {
await categoriesApi.remove(category.categoryId)
toast.success("Category deleted", category.name)
await categoriesApi.updateStatus(category.categoryId, next)
toast.success(next === "Inactive" ? "Category deactivated" : "Category activated", category.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update category status", errorMessage(err))
} finally {
setDeletingId(null)
}
@@ -206,6 +214,7 @@ export default function CategoriesPage() {
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
@@ -215,9 +224,21 @@ export default function CategoriesPage() {
<TableRow key={c.categoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
{/* Subcategories are their own resource now, not a nested tree. */}
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
<Button
variant="ghost"
size="icon-sm"
@@ -227,26 +248,31 @@ export default function CategoriesPage() {
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
<Trash2 className="size-4" />
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
description={
c.status === "Active"
? "The category stays on existing items but cannot take new subcategories or items."
: "The category becomes selectable again."
}
confirmLabel={c.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(c)}
/>
</AlertDialog>
</div>
@@ -2,15 +2,16 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, SwatchBook, Trash2 } from "lucide-react"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName } from "@/lib/validations/master-data"
import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { VariantCategory } from "@/types/master-data"
import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -19,22 +20,30 @@ import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function VariantsPage() {
const [categories, setCategories] = useState<VariantCategory[] | null>(null)
/**
* Item Types (docs/11 §2.7) the dimension names (Color, Size, Material) the item
* builder's checkboxes read. Formerly "Variant Categories" in this app.
*
* These are names only. The values (Red, S, M) live in each item's generated SKU and are
* not stored, so nothing here links to an item renaming a type leaves existing SKUs
* untouched.
*/
export default function ItemTypesPage() {
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<VariantCategory | null>(null)
const [editing, setEditing] = useState<ItemType | null>(null)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
function load() {
setError(null)
variantCategoriesApi
.list()
.then((res) => setCategories(res.items))
itemTypesApi
.list({ pageSize: 200 })
.then((res) => setItemTypes(res.items))
.catch((err) => setError(errorMessage(err)))
}
@@ -47,24 +56,28 @@ export default function VariantsPage() {
setOpen(true)
}
function openEditDialog(category: VariantCategory) {
setEditing(category)
setName(category.name)
function openEditDialog(itemType: ItemType) {
setEditing(itemType)
setName(itemType.name)
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const nextErrors = validateVariantCategoryName(name)
const nextErrors = validateItemTypeName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const category = editing
? await variantCategoriesApi.update(editing.variantCategoryId, { name })
: await variantCategoriesApi.create({ name })
toast.success(editing ? "Variant category updated" : "Variant category created", category.name)
if (editing) {
// Re-read for a fresh If-Match; a concurrent edit surfaces as 412.
const current = await itemTypesApi.get(editing.itemTypeId)
await itemTypesApi.update(editing.itemTypeId, { name }, current.etag ?? "")
} else {
await itemTypesApi.create({ name })
}
toast.success(editing ? "Item type updated" : "Item type created", name)
setOpen(false)
setName("")
setEditing(null)
@@ -72,22 +85,24 @@ export default function VariantsPage() {
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error(editing ? "Could not update category" : "Could not create category", errorMessage(err))
toast.error(editing ? "Could not update item type" : "Could not create item type", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleDelete(category: VariantCategory) {
setDeletingId(category.variantCategoryId)
/** Deactivate, never delete — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(itemType: ItemType) {
const next = itemType.status === "Active" ? "Inactive" : "Active"
setTogglingId(itemType.itemTypeId)
try {
await variantCategoriesApi.remove(category.variantCategoryId)
toast.success("Variant category deleted", category.name)
await itemTypesApi.updateStatus(itemType.itemTypeId, next)
toast.success(next === "Inactive" ? "Item type deactivated" : "Item type activated", itemType.name)
load()
} catch (err) {
toast.error("Could not delete category", errorMessage(err))
toast.error("Could not update status", errorMessage(err))
} finally {
setDeletingId(null)
setTogglingId(null)
}
}
@@ -99,23 +114,25 @@ export default function VariantsPage() {
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Variants</h1>
<p className="text-base text-muted-foreground">Variant categories used by the item variant builder (e.g. Color, Size, Material).</p>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
Dimensions the item builder offers (e.g. Color, Size, Material). Values are captured per item and encoded in its SKU.
</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Category</Button>} />
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Item Type</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit variant category" : "New variant category"}</DialogTitle>
<DialogDescription>Give the category a name.</DialogDescription>
<DialogTitle>{editing ? "Edit item type" : "New item type"}</DialogTitle>
<DialogDescription>Give the item type a name.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="variant-category-name">Name</FieldLabel>
<FieldLabel htmlFor="item-type-name">Name</FieldLabel>
<Input
id="variant-category-name"
id="item-type-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Material"
@@ -140,7 +157,7 @@ export default function VariantsPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && categories === null && (
{!error && itemTypes === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
@@ -148,37 +165,36 @@ export default function VariantsPage() {
</div>
)}
{!error && categories !== null && categories.length === 0 && (
{!error && itemTypes !== null && itemTypes.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SwatchBook className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No variant categories yet.</p>
<p className="text-base text-muted-foreground">No item types yet.</p>
</div>
)}
{!error && categories !== null && categories.length > 0 && (
{!error && itemTypes !== null && itemTypes.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((c) => (
<TableRow key={c.variantCategoryId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.variantCategoryId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
{itemTypes.map((t) => (
<TableRow key={t.itemTypeId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
<Pencil className="size-4" />
</Button>
@@ -188,20 +204,24 @@ export default function VariantsPage() {
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${c.name}`}
disabled={deletingId === c.variantCategoryId}
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
disabled={togglingId === t.itemTypeId}
/>
}
>
<Trash2 className="size-4" />
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${c.name}?`}
description="This permanently removes the variant category."
confirmLabel="Delete"
onConfirm={() => handleDelete(c)}
variant={t.status === "Active" ? "destructive" : "success"}
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
description={
t.status === "Active"
? "It disappears from the item builder. Existing items keep their SKUs — nothing references this record."
: "It reappears in the item builder."
}
confirmLabel={t.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(t)}
/>
</AlertDialog>
</div>
@@ -8,11 +8,13 @@ import { ArrowLeft, Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { brandsApi } from "@/lib/api/brands"
import { variantCategoriesApi } from "@/lib/api/variants"
import { itemTypesApi } from "@/lib/api/item-types"
import { productConfig } from "@/lib/api/product-config"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateVariantCategoryName, validateVariantItemForm } from "@/lib/validations/master-data"
import { validateItemTypeName, validateVariantItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, VariantCategory } from "@/types/master-data"
import { Brand, Category, ItemType, ProductConfig, SubCategory } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -34,6 +36,10 @@ function buildVariantSku(categoryLabel: string, values: string[]): string {
return [skuSegment(categoryLabel, 3), ...values.map((v) => skuSegment(v, 3))].join("-")
}
/**
* Colour is special-cased by name. This stays a frontend concern: item types are names
* only — there is no value table server-side to hang a hex column off (docs/10 Part C.9).
*/
function isColorCategory(categoryName: string): boolean {
return categoryName.trim().toLowerCase() === "color"
}
@@ -52,26 +58,31 @@ function partLabel(part: { name: string; value: string }): string {
return isColorCategory(part.name) ? decodeColorValue(part.value).name : part.value
}
// No Base UOM field on this form — every variant created here uses the base "EA" unit (uomId 1 in the seed data).
const DEFAULT_BASE_UOM_ID = 1
export default function NewItemPage() {
const router = useRouter()
const [categories, setCategories] = useState<Category[] | null>(null)
const [brands, setBrands] = useState<{ brandId: number; name: string }[] | null>(null)
const [variantCategories, setVariantCategories] = useState<VariantCategory[] | null>(null)
const [brands, setBrands] = useState<Brand[] | null>(null)
const [itemTypes, setItemTypes] = useState<ItemType[] | null>(null)
const [config, setConfig] = useState<ProductConfig | null>(null)
/**
* This form has no Base UOM field by design, so it adopts the first UOM as the base.
* It used to hardcode `uomId: 1`, which only worked because the mock seeded that id —
* against a real database that is a 422 waiting to happen, or worse, silently the wrong
* unit. Null here means "no UOM exists yet" and the form says so rather than guessing.
*/
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
const [brandId, setBrandId] = useState<number | null>(null)
const [checkedVariantCategoryIds, setCheckedVariantCategoryIds] = useState<number[]>([])
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
const [valuesByCategory, setValuesByCategory] = useState<Record<number, string[]>>({})
const [inputByCategory, setInputByCategory] = useState<Record<number, string>>({})
const [colorNameByCategory, setColorNameByCategory] = useState<Record<number, string>>({})
const [quantities, setQuantities] = useState<Record<string, string>>({})
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
@@ -83,23 +94,40 @@ export default function NewItemPage() {
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([categoriesApi.list({ pageSize: 200 }), brandsApi.list({ pageSize: 200 }), variantCategoriesApi.list()])
.then(([cat, br, vc]) => {
Promise.all([
categoriesApi.list({ pageSize: 200, status: "Active" }),
brandsApi.list({ pageSize: 200, status: "Active" }),
itemTypesApi.list({ pageSize: 200, status: "Active" }),
productConfig(),
uomsApi.list({ pageSize: 1 }),
])
.then(([cat, br, types, cfg, uoms]) => {
setCategories(cat.items)
setBrands(br.items)
setVariantCategories(vc.items)
setItemTypes(types.items)
setConfig(cfg)
setBaseUomId(uoms.items[0]?.uomId ?? null)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const topCategories = useMemo(() => (categories ?? []).filter((c) => c.parentId === null), [categories])
const subCategoryOptions = useMemo(
() => (categories ?? []).filter((c) => c.parentId === categoryId),
[categories, categoryId]
)
const effectiveCategoryId = subCategoryId ?? categoryId
const effectiveCategoryLabel =
(categories ?? []).find((c) => c.categoryId === effectiveCategoryId)?.name ?? ""
// Subcategories are their own resource now — fetched per category rather than filtered
// out of a flat list by parentId (that column no longer exists).
useEffect(() => {
if (categoryId === null || !config?.subcategoriesEnabled) {
setSubCategories([])
return
}
categoriesApi
.listSubCategories(categoryId, { pageSize: 200, status: "Active" })
.then((res) => setSubCategories(res.items))
.catch(() => setSubCategories([]))
}, [categoryId, config?.subcategoriesEnabled])
const categoryLabel = (categories ?? []).find((c) => c.categoryId === categoryId)?.name ?? ""
const subCategoryLabel = subCategories.find((s) => s.subCategoryId === subCategoryId)?.name ?? ""
/** SKU/name read best off the most specific level, but BOTH ids are sent to the server. */
const effectiveLabel = subCategoryLabel || categoryLabel
const brandLabel = (brands ?? []).find((b) => b.brandId === brandId)?.name ?? ""
function handleCategoryChange(value: number | null) {
@@ -107,28 +135,27 @@ export default function NewItemPage() {
setSubCategoryId(null)
}
function toggleVariantCategory(variantCategoryId: number) {
setCheckedVariantCategoryIds((prev) =>
prev.includes(variantCategoryId) ? prev.filter((id) => id !== variantCategoryId) : [...prev, variantCategoryId]
function toggleItemType(itemTypeId: number) {
setCheckedItemTypeIds((prev) =>
prev.includes(itemTypeId) ? prev.filter((id) => id !== itemTypeId) : [...prev, itemTypeId]
)
setQuantities({})
}
async function handleAddVariantCategory() {
const nextErrors = validateVariantCategoryName(newCategoryName)
async function handleAddItemType() {
const nextErrors = validateItemTypeName(newCategoryName)
if (nextErrors.name) {
setNewCategoryError(nextErrors.name)
return
}
setAddingCategorySubmitting(true)
try {
const category = await variantCategoriesApi.create({ name: newCategoryName })
setVariantCategories((prev) => [...(prev ?? []), category])
setCheckedVariantCategoryIds((prev) => [...prev, category.variantCategoryId])
const created = await itemTypesApi.create({ name: newCategoryName })
setItemTypes((prev) => [...(prev ?? []), created.data])
setCheckedItemTypeIds((prev) => [...prev, created.data.itemTypeId])
setNewCategoryName("")
setNewCategoryError(null)
setAddingCategory(false)
toast.success("Variant category created", category.name)
toast.success("Item type created", created.data.name)
} catch (err) {
setNewCategoryError(errorMessage(err))
} finally {
@@ -136,34 +163,32 @@ export default function NewItemPage() {
}
}
function addValue(variantCategoryId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[variantCategoryId] ?? "").trim()
function addValue(itemTypeId: number, overrideValue?: string) {
const value = (overrideValue ?? inputByCategory[itemTypeId] ?? "").trim()
if (value) {
setValuesByCategory((prev) => {
const existing = prev[variantCategoryId] ?? []
const existing = prev[itemTypeId] ?? []
if (existing.some((v) => v.toLowerCase() === value.toLowerCase())) return prev
return { ...prev, [variantCategoryId]: [...existing, value] }
return { ...prev, [itemTypeId]: [...existing, value] }
})
setQuantities({})
}
setInputByCategory((prev) => ({ ...prev, [variantCategoryId]: "" }))
setInputByCategory((prev) => ({ ...prev, [itemTypeId]: "" }))
}
function removeValue(variantCategoryId: number, value: string) {
function removeValue(itemTypeId: number, value: string) {
setValuesByCategory((prev) => ({
...prev,
[variantCategoryId]: (prev[variantCategoryId] ?? []).filter((v) => v !== value),
[itemTypeId]: (prev[itemTypeId] ?? []).filter((v) => v !== value),
}))
setQuantities({})
}
const activeCategories = useMemo(
() =>
(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => ({ ...vc, values: valuesByCategory[vc.variantCategoryId] ?? [] }))
.filter((vc) => vc.values.length > 0),
[variantCategories, checkedVariantCategoryIds, valuesByCategory]
(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => ({ ...t, values: valuesByCategory[t.itemTypeId] ?? [] }))
.filter((t) => t.values.length > 0),
[itemTypes, checkedItemTypeIds, valuesByCategory]
)
const variants = useMemo(() => {
@@ -183,44 +208,58 @@ export default function NewItemPage() {
}
return combinations.map((c) => ({
...c,
sku: buildVariantSku(effectiveCategoryLabel, c.parts.map(partLabel)),
sku: buildVariantSku(effectiveLabel, c.parts.map(partLabel)),
}))
}, [activeCategories, effectiveCategoryLabel])
}, [activeCategories, effectiveLabel])
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateVariantItemForm({ categoryId: effectiveCategoryId, hasVariants: variants.length > 0 })
const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (baseUomId === null) {
setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.")
return
}
setSubmitting(true)
let created = 0
try {
let created = 0
for (const variant of variants) {
const qty = Number(quantities[variant.key] || 0)
await itemsApi.create({
sku: variant.sku,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveCategoryLabel} - ${variant.parts.map(partLabel).join("/")}`,
categoryId: effectiveCategoryId as number,
name: `${brandLabel ? brandLabel + " " : ""}${effectiveLabel} - ${variant.parts.map(partLabel).join("/")}`,
// Both FKs travel: the old code sent `subCategoryId ?? categoryId` as the
// category, which lost the parent entirely. The server rejects a mismatched
// pair with 422.
categoryId: categoryId as number,
subCategoryId,
brandId,
baseUomId: DEFAULT_BASE_UOM_ID,
itemType: "Stocked",
baseUomId,
stockNature: "Stocked",
trackingMode: "None",
initialQty: Number.isFinite(qty) ? qty : 0,
})
created += 1
}
toast.success("Variants created", `${created} item${created === 1 ? "" : "s"} created`)
router.push("/dashboard/products")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create variants", errorMessage(err))
// Each row is its own POST with no transaction, so a failure partway (e.g. a
// duplicate SKU) leaves the earlier rows created. Say so rather than implying
// nothing happened.
const detail = errorMessage(err)
setSubmitError(
created > 0
? `${detail}${created} item${created === 1 ? "" : "s"} were already created before this failed.`
: detail,
)
toast.error("Could not create all variants", detail)
} finally {
setSubmitting(false)
}
}
const loading = !categories || !brands || !variantCategories
const loading = !categories || !brands || !itemTypes || !config
return (
<div className="flex flex-col gap-6">
@@ -230,7 +269,7 @@ export default function NewItemPage() {
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and variant categories (FR-MD-01).</p>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
</div>
</div>
@@ -240,6 +279,16 @@ export default function NewItemPage() {
{loading && !loadError && <Skeleton className="h-64 w-full" />}
{!loading && baseUomId === null && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-base text-amber-900">
No unit of measure exists yet. Items need a base UOM {" "}
<Link href="/dashboard/products/uoms" className="font-semibold underline">
create one first
</Link>
.
</div>
)}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -250,7 +299,7 @@ export default function NewItemPage() {
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{topCategories.map((c) => (
{(categories ?? []).map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
@@ -259,54 +308,63 @@ export default function NewItemPage() {
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategoryOptions.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategoryOptions.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategoryOptions.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Config flags are honoured by hiding the field: sending a gated value would
just earn a 422 CONFIG_DISABLED (docs/11 §2.8). */}
{config?.subcategoriesEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Subcategory (optional)</Label>
<Select<number | null> value={subCategoryId} onValueChange={setSubCategoryId} disabled={subCategories.length === 0}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder={subCategories.length === 0 ? "No subcategories" : "Select subcategory"} />
</SelectTrigger>
<SelectContent>
{subCategories.map((s) => (
<SelectItem key={s.subCategoryId} value={s.subCategoryId} className="text-base">
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config?.brandsEnabled && (
<div className="flex flex-col gap-2">
<Label className="text-base">Brand (optional)</Label>
<Select<number | null> value={brandId} onValueChange={setBrandId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select brand" />
</SelectTrigger>
<SelectContent>
{(brands ?? []).map((b) => (
<SelectItem key={b.brandId} value={b.brandId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Variants</h2>
<h2 className="text-lg font-semibold text-foreground">Item types</h2>
<p className="text-sm text-muted-foreground">
Check the variant categories that apply, then add their values to generate a SKU per combination.
Check the item types that apply, then add their values to generate a SKU per combination.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
{(variantCategories ?? []).map((vc) => (
<label key={vc.variantCategoryId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
{(itemTypes ?? []).map((t) => (
<label key={t.itemTypeId} className="flex items-center gap-2.5 rounded-lg border px-3 py-2 hover:bg-muted/50">
<Checkbox
checked={checkedVariantCategoryIds.includes(vc.variantCategoryId)}
onCheckedChange={() => toggleVariantCategory(vc.variantCategoryId)}
checked={checkedItemTypeIds.includes(t.itemTypeId)}
onCheckedChange={() => toggleItemType(t.itemTypeId)}
/>
<span className="text-base font-medium">{vc.name}</span>
<span className="text-base font-medium">{t.name}</span>
</label>
))}
{!addingCategory && (
@@ -314,7 +372,7 @@ export default function NewItemPage() {
type="button"
variant="outline"
size="icon-sm"
aria-label="Add another variant category"
aria-label="Add another item type"
onClick={() => setAddingCategory(true)}
>
<Plus className="size-4" />
@@ -331,7 +389,7 @@ export default function NewItemPage() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddVariantCategory()
handleAddItemType()
}
}}
placeholder="Material"
@@ -339,7 +397,7 @@ export default function NewItemPage() {
aria-invalid={!!newCategoryError}
autoFocus
/>
<Button type="button" onClick={handleAddVariantCategory} disabled={addingCategorySubmitting}>
<Button type="button" onClick={handleAddItemType} disabled={addingCategorySubmitting}>
<Plus className="size-4" />
Add
</Button>
@@ -363,38 +421,38 @@ export default function NewItemPage() {
<FieldError errors={[errors.variants ? { message: errors.variants } : undefined]} />
{checkedVariantCategoryIds.length > 0 && (
{checkedItemTypeIds.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(variantCategories ?? [])
.filter((vc) => checkedVariantCategoryIds.includes(vc.variantCategoryId))
.map((vc) => {
const isColor = isColorCategory(vc.name)
const currentInput = inputByCategory[vc.variantCategoryId] ?? ""
const currentColorName = colorNameByCategory[vc.variantCategoryId] ?? ""
{(itemTypes ?? [])
.filter((t) => checkedItemTypeIds.includes(t.itemTypeId))
.map((t) => {
const isColor = isColorCategory(t.name)
const currentInput = inputByCategory[t.itemTypeId] ?? ""
const currentColorName = colorNameByCategory[t.itemTypeId] ?? ""
function addColor() {
const name = currentColorName.trim()
if (!name) return
addValue(vc.variantCategoryId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: "" }))
addValue(t.itemTypeId, encodeColorValue(name, currentInput || "#EF4444"))
setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: "" }))
}
return (
<div key={vc.variantCategoryId} className="flex flex-col gap-2">
<Label className="text-base">{vc.name} values</Label>
<div key={t.itemTypeId} className="flex flex-col gap-2">
<Label className="text-base">{t.name} values</Label>
<div className="flex gap-2">
{isColor ? (
<>
<input
type="color"
value={currentInput || "#EF4444"}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
className="h-11 w-11 shrink-0 cursor-pointer rounded-md border border-input p-0.5"
aria-label="Pick color"
/>
<Input
value={currentColorName}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setColorNameByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
@@ -408,24 +466,24 @@ export default function NewItemPage() {
) : (
<Input
value={currentInput}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [vc.variantCategoryId]: e.target.value }))}
onChange={(e) => setInputByCategory((prev) => ({ ...prev, [t.itemTypeId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addValue(vc.variantCategoryId)
addValue(t.itemTypeId)
}
}}
placeholder={vc.name}
placeholder={t.name}
className="h-11 text-base"
/>
)}
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(vc.variantCategoryId))}>
<Button type="button" variant="outline" onClick={() => (isColor ? addColor() : addValue(t.itemTypeId))}>
<Plus className="size-4" />
Add {vc.name}
Add {t.name}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(valuesByCategory[vc.variantCategoryId] ?? []).map((v) => {
{(valuesByCategory[t.itemTypeId] ?? []).map((v) => {
const decoded = isColor ? decodeColorValue(v) : null
return (
<Badge key={v} variant="outline" className="h-7 gap-1 pr-1 text-sm">
@@ -439,7 +497,7 @@ export default function NewItemPage() {
{decoded ? decoded.name : v}
<button
type="button"
onClick={() => removeValue(vc.variantCategoryId, v)}
onClick={() => removeValue(t.itemTypeId, v)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${decoded ? decoded.name : v}`}
>
@@ -461,10 +519,13 @@ export default function NewItemPage() {
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
{activeCategories.map((cat) => (
<TableHead key={cat.variantCategoryId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm text-indigo-700">Quantity</TableHead>
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow — stock arrives via a GRN.
The input was informational-only under the mock and would now be a
field that silently discards what you type. */}
</TableRow>
</TableHeader>
<TableBody>
@@ -488,16 +549,6 @@ export default function NewItemPage() {
)
})}
<TableCell className="px-3 py-2.5 font-medium">{variant.sku}</TableCell>
<TableCell className="px-3 py-2.5">
<Input
type="number"
min="0"
value={quantities[variant.key] ?? ""}
onChange={(e) => setQuantities((prev) => ({ ...prev, [variant.key]: e.target.value }))}
placeholder="0"
className="h-9 w-24 text-sm"
/>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -505,6 +556,7 @@ export default function NewItemPage() {
</div>
)}
</div>
)}
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
@@ -191,7 +191,7 @@ export default function ItemsPage() {
</TableCell>
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
<TableCell className="px-3 py-3.5">{item.itemType}</TableCell>
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
@@ -0,0 +1,168 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Info } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) — the singleton feature gate.
*
* Only three flags exist. `subcategoriesEnabled`/`brandsEnabled` are enforced by the
* server (an item write carrying a gated field gets 422 CONFIG_DISABLED);
* `itemTypesEnabled` is advisory — items hold no item-type reference, so the frontend
* hiding the builder's type section IS the enforcement. That distinction is surfaced in
* the UI rather than hidden, because it changes what "off" actually guarantees.
*/
export default function ProductSettingsPage() {
const [config, setConfig] = useState<ProductConfig | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<keyof ProductConfig | null>(null)
function load() {
setError(null)
productConfigApi
.get()
.then((res) => {
setConfig(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function toggle(flag: "subcategoriesEnabled" | "brandsEnabled" | "itemTypesEnabled", next: boolean) {
if (!config) return
setSaving(flag)
try {
// All three flags are always sent — the server rejects a partial body (400), which
// is what stops an omitted flag from silently switching a feature off.
const res = await productConfigApi.update(
{
subcategoriesEnabled: config.subcategoriesEnabled,
brandsEnabled: config.brandsEnabled,
itemTypesEnabled: config.itemTypesEnabled,
[flag]: next,
},
etag ?? "",
)
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `${LABELS[flag]} ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load() // a 412 means someone else changed it — resync rather than retry blind
} finally {
setSaving(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground">
Switch optional product features on or off for this deployment (FR-MD-11).
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !config && <Skeleton className="h-64 w-full" />}
{!error && config && (
<div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="text-lg font-semibold text-foreground">Product Capabilities</h2>
<ToggleRow
label="Subcategories"
description="Adds one optional level below a category. Off ⇒ items attach directly to a category."
checked={config.subcategoriesEnabled}
busy={saving === "subcategoriesEnabled"}
onChange={(v) => toggle("subcategoriesEnabled", v)}
/>
<ToggleRow
label="Brands"
description="Items may carry a brand."
checked={config.brandsEnabled}
busy={saving === "brandsEnabled"}
onChange={(v) => toggle("brandsEnabled", v)}
/>
<ToggleRow
label="Item types"
description="The item builder offers Color / Size / Material dimensions when creating items."
checked={config.itemTypesEnabled}
busy={saving === "itemTypesEnabled"}
onChange={(v) => toggle("itemTypesEnabled", v)}
note="Advisory: the app honours this, but the server cannot enforce it — items store no item-type reference. Turning it off hides the builder's section; it does not reject anything."
/>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
</p>
)}
</div>
)}
</div>
)
}
const LABELS: Record<string, string> = {
subcategoriesEnabled: "Subcategories",
brandsEnabled: "Brands",
itemTypesEnabled: "Item types",
}
function ToggleRow({
label,
description,
checked,
busy,
onChange,
note,
}: {
label: string
description: string
checked: boolean
busy: boolean
onChange: (next: boolean) => void
note?: string
}) {
return (
<div className="flex items-start justify-between gap-6 border-t py-4 first:border-t-0">
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">{label}</span>
<span className="text-sm text-muted-foreground">{description}</span>
{note && (
<span className="mt-1 inline-flex items-start gap-1.5 text-sm text-amber-700">
<Info className="mt-0.5 size-4 shrink-0" />
{note}
</span>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} disabled={busy} aria-label={label} />
</div>
)
}
@@ -1,444 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn"
import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
binId: number | null
qty: string
unitCost: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `egline-${keySeq}`
}
function emptyLine(): DraftLine {
return {
key: newKey(),
poLineId: null,
itemId: null,
uomId: null,
binId: null,
qty: "",
unitCost: "",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
}
}
export default function EditGrnPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([])
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!Number.isFinite(grnId)) return
Promise.all([
grnsApi.get(grnId),
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
])
.then(([g, wh, it, uo]) => {
if (g.status !== "Draft") {
setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`)
setGrn(g)
return
}
setGrn(g)
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setWarehouseId(g.warehouseId)
setLines(
g.lines.map(
(l): DraftLine => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: l.binId,
qty: String(l.qty),
unitCost: String(l.unitCost),
holdStatus: l.holdStatus,
batchNo: "",
expiryDate: "",
serialNumbersText: "",
})
)
)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [grnId])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeLine(key: string) {
setLines((prev) => prev.filter((l) => l.key !== key))
}
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
async function handleSubmit() {
if (!grn) return
setSubmitError(null)
setHeaderError(null)
if (!warehouseId) {
setHeaderError("Select a warehouse.")
return
}
if (lines.length === 0) {
setSubmitError("Add at least one line.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const errors = validateLine({
itemId: line.itemId,
uomId: line.uomId,
qty: line.qty,
unitCost: line.unitCost,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
uomId: l.uomId as number,
binId: l.binId,
qty: Number(l.qty),
unitCost: Number(l.unitCost),
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
}
})
setSubmitting(true)
try {
const updated = await grnsApi.update(grn.grnId, {
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN updated", `${updated.docNo} saved.`)
router.push(`/dashboard/receiving/grn/${updated.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not update GRN", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (loadError) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Edit GRN</h1>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
</div>
)
}
const loading = !grn || !warehouses || !items || !uoms
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link
href={grn ? `/dashboard/receiving/grn/${grn.grnId}` : "/dashboard/receiving/grn"}
className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Edit {grn?.docNo ?? "GRN"}</h1>
<p className="text-base text-muted-foreground">Only Draft GRNs can be edited confirming posts stock layers permanently.</p>
</div>
</div>
{loading && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{headerError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{headerError}</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
</div>
{lines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-48 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const item = itemFor(line.itemId)
const errors = lineErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
<SelectValue placeholder="UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<HoldStatus> value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link
href={`/dashboard/receiving/grn/${grnId}`}
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Saving…" : "Save changes"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -50,7 +50,7 @@ export default function GrnDetailPage() {
useEffect(() => {
if (!grn) return
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(grn.warehouseId).then(setBins).catch(() => setBins([]))
}, [grn?.warehouseId])
function itemFor(itemId: number) {
@@ -112,7 +112,7 @@ export default function NewGrnPage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
function switchMode(next: Mode) {
@@ -2,14 +2,13 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
@@ -23,7 +22,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
@@ -40,7 +38,6 @@ export default function GrnListPage() {
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
// Debounce the search box so typing doesn't refetch on every keystroke.
useEffect(() => {
@@ -71,19 +68,6 @@ export default function GrnListPage() {
useEffect(load, [page, query, status])
async function handleDelete(grn: GrnSummary) {
setDeletingId(grn.grnId)
try {
await grnsApi.remove(grn.grnId)
toast.success("GRN deleted", `${grn.docNo} has been removed.`)
load()
} catch (err) {
toast.error("Could not delete GRN", errorMessage(err))
} finally {
setDeletingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
return (
@@ -196,7 +180,6 @@ export default function GrnListPage() {
</TableHeader>
<TableBody>
{grns.map((grn) => {
const isDraft = grn.status === "Draft"
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
@@ -224,47 +207,9 @@ export default function GrnListPage() {
<Eye className="size-4" />
</Link>
{isDraft ? (
<Link
href={`/dashboard/receiving/grn/${grn.grnId}/edit`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${grn.docNo}`}
>
<Pencil className="size-4" />
</Link>
) : (
<Button
variant="ghost"
size="icon-sm"
disabled
aria-label={`Edit ${grn.docNo} (not editable once ${grn.status.toLowerCase()})`}
>
<Pencil className="size-4" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${grn.docNo}`}
disabled={!isDraft || deletingId === grn.grnId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${grn.docNo}?`}
description="This permanently removes the draft GRN. It has not been confirmed, so no stock layers or ledger entries exist yet."
confirmLabel="Delete"
onConfirm={() => handleDelete(grn)}
/>
</AlertDialog>
{/* Edit/Delete removed 2026-07-17: the API has no PUT or DELETE for
a GRN. A receipt is corrected with a reversing document, never
edited or erased (FR-X-05). */}
</div>
</TableCell>
</TableRow>
@@ -28,9 +28,9 @@ export default function StockEnquiryPage() {
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
useEffect(() => {
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
Promise.all([stockApi.onHandList({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([r, it, wh]) => {
setRows(r)
setRows(r.items)
setItems(it.items)
setWarehouses(wh.items)
})
@@ -42,9 +42,11 @@ export default function ReorderAlertsPage() {
const key = `${alert.itemId}-${alert.warehouseId}`
setRequesting(key)
try {
// The server returns the full requisition; the suggested qty is on its line.
const res = await stockApi.createReorderRequisition(alert.itemId, alert.warehouseId)
setRequested((prev) => new Set(prev).add(key))
toast.success("Requisition created", `${res.docNo} for ${res.qty} units.`)
const qty = res.lines[0]?.qty ?? alert.suggestedRequisitionQty
toast.success("Requisition created", `${res.docNo} for ${qty} units.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
@@ -71,7 +71,7 @@ export default function NewTransferPage() {
setSrcBins([])
return
}
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
warehousesApi.listBins(srcWarehouseId).then(setSrcBins).catch(() => setSrcBins([]))
}, [srcWarehouseId])
useEffect(() => {
@@ -79,7 +79,7 @@ export default function NewTransferPage() {
setDestBins([])
return
}
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
warehousesApi.listBins(destWarehouseId).then(setDestBins).catch(() => setDestBins([]))
}, [destWarehouseId])
function updateLine(key: string, patch: Partial<DraftLine>) {
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
import { wastageApi, wastageReasonCodeIds } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -42,10 +42,9 @@ export default function NewWastagePage() {
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setWarehouses(wh.items)
setItems(it.items)
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
@@ -55,7 +54,7 @@ export default function NewWastagePage() {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
warehousesApi.listBins(warehouseId).then(setBins).catch(() => setBins([]))
}, [warehouseId])
async function handleSubmit() {
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
import { wastageApi, wastageReasonCodeIds, WastageRecord } from "@/lib/api/wastage"
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
@@ -33,8 +33,7 @@ export default function WastagePage() {
useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([rc, it, wh]) => {
const wastageIds = new Set(wastageReasonCodeIds())
setReasonCodes(rc.items.filter((r) => wastageIds.has(r.reasonCodeId)))
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
setItems(it.items)
setWarehouses(wh.items)
})
@@ -40,7 +40,7 @@ export default function WarehouseDetailPage() {
const [submitting, setSubmitting] = useState(false)
function loadBins() {
warehousesApi.listBins(warehouseId).then((res) => setBins(res.items)).catch((err) => setError(errorMessage(err)))
warehousesApi.listBins(warehouseId).then(setBins).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
@@ -41,7 +41,7 @@ export default function WarehousesPage() {
.then(async (res) => {
setWarehouses(res.items)
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
setBins(allBins.flatMap((b) => b.items))
setBins(allBins.flat())
})
.catch((err) => setError(errorMessage(err)))
}
+23 -3
View File
@@ -3,11 +3,14 @@
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Eye, EyeOff } from "lucide-react"
import { loginSchema, LoginValues } from "@/lib/validations"
import { authApi } from "@/lib/api/auth"
import { setStoredUser } from "@/lib/auth-session"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -39,8 +42,10 @@ function GoogleIcon() {
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
@@ -48,8 +53,18 @@ export default function LoginPage() {
})
const onSubmit = form.handleSubmit(async (values) => {
console.log(values)
router.push("/dashboard")
setSubmitError(null)
try {
// The session arrives as httpOnly cookies; the body carries only the user profile.
const session = await authApi.login({ identifier: values.email, password: values.password })
setStoredUser(session.user)
// Honour the path the guard bounced us from, but only same-origin paths — an
// attacker-supplied absolute URL here would be an open redirect.
const next = searchParams.get("next")
router.push(next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard")
} catch (err) {
setSubmitError(errorMessage(err))
}
})
return (
@@ -79,6 +94,11 @@ export default function LoginPage() {
</div>
<form onSubmit={onSubmit} noValidate className="mt-10 space-y-6" aria-describedby="form-errors" aria-live="polite">
{submitError && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{submitError}
</div>
)}
<FieldGroup className="space-y-5">
<Field data-invalid={!!form.formState.errors.email}>
<FieldLabel htmlFor="email" className="text-sm font-semibold text-foreground">
@@ -14,8 +14,10 @@ import {
Menu,
Package,
PackageCheck,
Ruler,
Settings,
ShoppingCart,
SlidersHorizontal,
SwatchBook,
Tag,
Truck,
@@ -43,7 +45,9 @@ const navItems: {
{ title: "Item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "Variant", href: "/dashboard/products/variants", icon: SwatchBook },
{ title: "Item Type", href: "/dashboard/products/item-types", icon: SwatchBook },
{ title: "UOM", href: "/dashboard/products/uoms", icon: Ruler },
{ title: "Configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
],
},
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
@@ -1,10 +1,13 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
import { AuthUser } from "@/types/auth"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@@ -17,6 +20,14 @@ import {
} from "@/components/ui/dropdown-menu"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
/** Avatar initials from a display name: "ERP Admin" -> "EA", "erpadmin" -> "ER". */
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return "?"
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
const PROCUREMENT_TITLES: Record<string, string> = {
"/dashboard/procurement": "Procurement",
"/dashboard/procurement/requisitions": "Requisitions",
@@ -69,6 +80,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brands"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (/^\/dashboard\/products\/[^/]+$/.test(pathname)) return "Item"
const segment = pathname.split("/").filter(Boolean).pop() ?? "dashboard"
@@ -122,9 +137,31 @@ export function Header() {
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
// Read after mount, not during render: localStorage doesn't exist on the server, and
// reading it while rendering would desync the hydration pass.
const [user, setUser] = useState<AuthUser | null>(null)
useEffect(() => setUser(getStoredUser()), [])
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
async function handleLogout() {
try {
// Always call it, even without a userId: AuthHex returns none on login, so the
// server resolves the user from the session token. Skipping the call would leave
// the erp_at cookie alive and the "logout" would be cosmetic.
await authApi.logout(user?.userId ?? null)
} catch {
// A failed logout must not strand the user in the app: the cookie may already be
// gone or upstream may be down. Clear locally and leave either way — worst case the
// server-side session lapses on its own.
} finally {
clearStoredUser()
router.push("/login")
router.refresh()
}
}
return (
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
<div className="flex items-center gap-3">
@@ -212,17 +249,17 @@ export function Header() {
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
<Avatar>
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
JM
{initials(displayName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
John Martinez
{displayName(user)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-slate-900">John Martinez</p>
<p className="text-sm font-normal text-muted-foreground">john52martinez@gmail.com</p>
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -242,7 +279,7 @@ export function Header() {
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
render={<Link href="/login" />}
onClick={handleLogout}
className="gap-3 px-3 py-2.5 text-base [&_svg:not([class*='size-'])]:size-5"
>
<LogOut />
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+109
View File
@@ -0,0 +1,109 @@
// Single typed fetch client for the ERPCore API (docs/20-FRONTEND.md §1). Per-endpoint
// methods live in lib/api/*.ts — no scattered fetch() in components.
//
// Transport: the API is reached SAME-ORIGIN through the Next rewrite in next.config.ts
// (/api/* -> BACKEND_ORIGIN). That is why API_BASE is relative and why no CORS setup
// exists on the backend: there is no cross-origin request to allow.
//
// Auth: the session is an httpOnly `erp_at` cookie issued by POST /auth/login (docs/11
// §2.0) — there is no bearer token to read, and by design JS cannot read the cookie.
// `credentials: "include"` is what actually authenticates every call.
import { ApiResult, ProblemDetails } from "@/types/common"
const API_BASE = "/api/v1"
/** Normalized RFC 7807 error (docs/11-BACKEND-PHASE1.md §1.8) thrown on any non-2xx response. */
export class ApiError extends Error {
status: number
code?: string
detail?: string
errors?: Record<string, string[]>
traceId?: string
constructor(problem: ProblemDetails) {
super(problem.title || "Request failed")
this.status = problem.status
this.code = problem.code
this.detail = problem.detail
this.errors = problem.errors
this.traceId = problem.traceId
}
}
export interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown
/** Sent as If-Match for concurrency-guarded PUT/PATCH (docs/11 §1.6). Echo the ETag verbatim, quotes included. */
ifMatch?: string
/** Sent as Idempotency-Key for transactional POSTs (e.g. GRN confirm, docs/11 §1.6). */
idempotencyKey?: string
/** Sent as X-XSRF-TOKEN. Only the [ValidateCsrf] actions on AuthController need it (docs/11 §2.0). */
csrf?: boolean
}
/** Reads the non-httpOnly CSRF cookie. It rotates on every session write, so read it per call. */
export function readCsrfToken(): string | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
async function rawRequest(path: string, options: RequestOptions = {}): Promise<Response> {
const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options
const csrfToken = csrf ? readCsrfToken() : null
const finalHeaders: Record<string, string> = {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(ifMatch ? { "If-Match": ifMatch } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}),
...((headers as Record<string, string> | undefined) ?? {}),
}
const response = await fetch(`${API_BASE}${path}`, {
...rest,
credentials: "include", // sends erp_at; the whole auth story depends on this
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
let problem: ProblemDetails
try {
problem = (await response.json()) as ProblemDetails
} catch {
// e.g. a proxy/network failure with a non-JSON body.
problem = { title: response.statusText || "Request failed", status: response.status }
}
if (!problem.status) problem.status = response.status
throw new ApiError(problem)
}
return response
}
/** Fire a request and decode the JSON body only (no ETag needed). */
export async function apiRequest<T>(path: string, options?: RequestOptions): Promise<T> {
const response = await rawRequest(path, options)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
/** Fire a request and also surface the ETag header, for resources that support If-Match. */
export async function apiRequestWithETag<T>(path: string, options?: RequestOptions): Promise<ApiResult<T>> {
const response = await rawRequest(path, options)
const etag = response.headers.get("ETag")
const data = response.status === 204 ? (undefined as T) : ((await response.json()) as T)
return { data, etag }
}
/** Build a `?a=1&b=2` query string, dropping null/undefined/empty values. */
export function buildQuery(params: object): string {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params) as [string, string | number | boolean | null | undefined][]) {
if (value === null || value === undefined || value === "") continue
search.set(key, String(value))
}
const qs = search.toString()
return qs ? `?${qs}` : ""
}
+32
View File
@@ -0,0 +1,32 @@
// Auth endpoints (docs/11-BACKEND-PHASE1.md §2.0). ERPCore proxies the AuthHex IdP and
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
import { apiRequest } from "@/lib/api-client"
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
export const authApi = {
/** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */
login(request: LoginRequest): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/login", { method: "POST", body: request })
},
/** Also issues a session, same as login. */
register(request: RegisterRequest): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/register", { method: "POST", body: request })
},
/**
* Clears all three cookies server-side.
*
* `userId` is optional and normally null: AuthHex omits it from its own login response,
* so the browser never learns it. The server falls back to the session token's UserId
* claim, and clears the cookies regardless of what the upstream revoke does.
*/
logout(userId: string | null = null): Promise<void> {
return apiRequest<void>("/auth/logout", { method: "POST", body: { userId } })
},
/** Exchanges the path-scoped erp_rt cookie for a fresh session. */
refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
},
}
+21 -46
View File
@@ -1,64 +1,39 @@
// One typed client method per Brand endpoint, mirroring lib/api/uoms.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
// One typed client method per Brand endpoint (docs/11-BACKEND-PHASE1.md §2.6; FR-MD-09).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { Brand, CreateBrandRequest, UpdateBrandRequest } from "@/types/master-data"
import { allocateBrandId, mockBrands, mockDelay } from "@/lib/api/mock-data"
export interface ListBrandsParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
status?: EntityStatus
/** `name` / `-name` etc. (docs/11 §1.5). */
sort?: string
}
export const brandsApi = {
list(params: ListBrandsParams = {}): Promise<PagedResponse<Brand>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockBrands
.filter((b) => !term || b.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<Brand>>(`/brands${buildQuery(params)}`)
},
create(request: CreateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
if (mockBrands.some((b) => b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
const brand: Brand = { brandId: allocateBrandId(), name, createdAt: new Date().toISOString() }
mockBrands.push(brand)
return mockDelay(brand)
get(brandId: number): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>(`/brands/${brandId}`)
},
update(brandId: number, request: UpdateBrandRequest): Promise<Brand> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Brand name is required."))
const brand = mockBrands.find((b) => b.brandId === brandId)
if (!brand) return Promise.reject(new Error("Brand not found."))
if (mockBrands.some((b) => b.brandId !== brandId && b.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Brand "${name}" already exists.`))
}
brand.name = name
return mockDelay(brand)
create(request: CreateBrandRequest): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>("/brands", { method: "POST", body: request })
},
remove(brandId: number): Promise<void> {
const index = mockBrands.findIndex((b) => b.brandId === brandId)
if (index === -1) return Promise.reject(new Error("Brand not found."))
mockBrands.splice(index, 1)
return mockDelay(undefined)
update(brandId: number, request: UpdateBrandRequest, ifMatch: string): Promise<ApiResult<Brand>> {
return apiRequestWithETag<Brand>(`/brands/${brandId}`, { method: "PUT", body: request, ifMatch })
},
/**
* Deactivate/reactivate. There is no DELETE anywhere in the API: masters referenced by
* transactions are deactivated, never removed (FR-MD-08).
*/
updateStatus(brandId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/brands/${brandId}/status`, { method: "PATCH", body: { status } })
},
}
+64 -46
View File
@@ -1,62 +1,80 @@
// One typed client method per Category endpoint (docs/11-BACKEND-PHASE1.md §2.3, FR-MD-04).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { Category, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/master-data"
import { allocateCategoryId, mockCategories, mockDelay } from "@/lib/api/mock-data"
// One typed client method per Category / SubCategory endpoint
// (docs/11-BACKEND-PHASE1.md §2.3; FR-MD-04).
//
// The hierarchy is exactly two levels. Categories no longer self-nest — `parentId` and
// `GET /categories?tree=true` were removed on 2026-07-16 — so there is no tree() here.
// Subcategories are listed/created under their parent; updates address them by their own id.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
Category,
CreateCategoryRequest,
CreateSubCategoryRequest,
SubCategory,
UpdateCategoryRequest,
UpdateSubCategoryRequest,
} from "@/types/master-data"
export interface ListCategoriesParams {
page?: number
pageSize?: number
q?: string
sortOrder?: "asc" | "desc"
status?: EntityStatus
sort?: string
}
export type ListSubCategoriesParams = ListCategoriesParams
export const categoriesApi = {
list(params: ListCategoriesParams = {}): Promise<PagedResponse<Category>> {
const term = params.q?.trim().toLowerCase()
const sortOrder = params.sortOrder ?? "asc"
const filtered = mockCategories
.filter((c) => !term || c.name.toLowerCase().includes(term))
.sort((a, b) => (sortOrder === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)))
return apiRequest<PagedResponse<Category>>(`/categories${buildQuery(params)}`)
},
const page = params.page ?? 1
const pageSize = params.pageSize ?? 5
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
get(categoryId: number): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>(`/categories/${categoryId}`)
},
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
create(request: CreateCategoryRequest): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>("/categories", { method: "POST", body: request })
},
update(categoryId: number, request: UpdateCategoryRequest, ifMatch: string): Promise<ApiResult<Category>> {
return apiRequestWithETag<Category>(`/categories/${categoryId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(categoryId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/categories/${categoryId}/status`, { method: "PATCH", body: { status } })
},
/** 404s if the parent category does not exist. */
listSubCategories(categoryId: number, params: ListSubCategoriesParams = {}): Promise<PagedResponse<SubCategory>> {
return apiRequest<PagedResponse<SubCategory>>(`/categories/${categoryId}/subcategories${buildQuery(params)}`)
},
createSubCategory(categoryId: number, request: CreateSubCategoryRequest): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/categories/${categoryId}/subcategories`, {
method: "POST",
body: request,
})
},
}
export const subCategoriesApi = {
get(subCategoryId: number): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/subcategories/${subCategoryId}`)
},
/** Name only — a subcategory cannot be moved to another category (docs/11 §2.3). */
update(subCategoryId: number, request: UpdateSubCategoryRequest, ifMatch: string): Promise<ApiResult<SubCategory>> {
return apiRequestWithETag<SubCategory>(`/subcategories/${subCategoryId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
create(request: CreateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const parentId = request.parentId ?? null
if (parentId !== null && !mockCategories.some((c) => c.categoryId === parentId)) {
return Promise.reject(new Error("Selected parent category does not exist."))
}
const category: Category = { categoryId: allocateCategoryId(), name, parentId, createdAt: new Date().toISOString() }
mockCategories.push(category)
return mockDelay(category)
},
update(categoryId: number, request: UpdateCategoryRequest): Promise<Category> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockCategories.find((c) => c.categoryId === categoryId)
if (!category) return Promise.reject(new Error("Category not found."))
category.name = name
return mockDelay(category)
},
remove(categoryId: number): Promise<void> {
const index = mockCategories.findIndex((c) => c.categoryId === categoryId)
if (index === -1) return Promise.reject(new Error("Category not found."))
mockCategories.splice(index, 1)
return mockDelay(undefined)
updateStatus(subCategoryId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/subcategories/${subCategoryId}/status`, { method: "PATCH", body: { status } })
},
}
+24 -186
View File
@@ -1,223 +1,61 @@
// One typed client method per GRN endpoint (docs/11-BACKEND-PHASE1.md §4).
// In-memory mock store (lib/api/mock-data.ts) — no backend API calls. Note
// GET /grns and GET /grns/{id} are not in docs/11-BACKEND-PHASE1.md §4 — see
// the note in Frontend/PROGRESS.md §4.
//
// `confirm` is the transactional one: the SERVER creates the FIFO layers, posts the
// inbound ledger and accrues PO receipts (FR-GRN-06). This client only triggers it and
// renders the returned side effects — the browser no longer does inventory maths.
//
// There is deliberately no update()/remove(): the API has no PUT or DELETE for a GRN.
// A confirmed receipt is corrected with a reversing document, never edited (FR-X-05).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ConfirmGrnResponse,
CreateGrnRequest,
CreatedLayer,
Grn,
GrnStatus,
GrnSummary,
ReleaseAction,
ReleaseGrnLineResponse,
} from "@/types/grn"
import { allocateGrnId, allocateGrnLineId, mockDelay, mockGrns, mockPurchaseOrders, receiveLayer } from "@/lib/api/mock-data"
export interface ListGrnsParams {
page?: number
pageSize?: number
/** Free-text search over doc no. and vendor/PO/warehouse id (docs/11 §1.5). */
q?: string
status?: GrnStatus
poId?: number
vendorId?: number
warehouseId?: number
}
function toSummary(grn: Grn): GrnSummary {
return {
grnId: grn.grnId,
docNo: grn.docNo,
poId: grn.poId,
vendorId: grn.vendorId,
warehouseId: grn.warehouseId,
status: grn.status,
createdAt: grn.createdAt,
}
sort?: string
}
export const grnsApi = {
list(params: ListGrnsParams = {}): Promise<PagedResponse<GrnSummary>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockGrns
.filter((g) => !params.status || g.status === params.status)
.filter((g) => !params.poId || g.poId === params.poId)
.filter((g) => !params.warehouseId || g.warehouseId === params.warehouseId)
.filter((g) => {
if (!term) return true
const haystack = [g.docNo, String(g.poId ?? ""), String(g.vendorId), String(g.warehouseId)]
.join(" ")
.toLowerCase()
return haystack.includes(term)
})
.map(toSummary)
.sort((a, b) => b.grnId - a.grnId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<GrnSummary>>(`/grns${buildQuery(params)}`)
},
get(grnId: number): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
return mockDelay(grn)
return apiRequest<Grn>(`/grns/${grnId}`)
},
/** 422 OVER_RECEIPT_TOLERANCE if qty exceeds the PO's open qty beyond tolerance. */
create(request: CreateGrnRequest): Promise<Grn> {
const grnId = allocateGrnId()
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
const grn: Grn = {
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: request.poId ?? null,
// Vendor is derived from the PO when receiving against one (as the real
// backend does) — request.vendorId is only meaningful for a direct receipt.
vendorId: referencedPo?.vendorId ?? request.vendorId ?? 0,
warehouseId: request.warehouseId,
status: "Draft",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
})),
}
mockGrns.push(grn)
return mockDelay(grn)
return apiRequest<Grn>("/grns", { method: "POST", body: request })
},
/**
* Posts the receipt. Pass a stable idempotencyKey per detail-page session so a retry
* cannot double-post stock — unlike the old mock, the server genuinely dedupes on it.
*/
confirm(grnId: number, idempotencyKey?: string): Promise<ConfirmGrnResponse> {
void idempotencyKey // real backend dedupes on this; the mock always reprocesses
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status === "Confirmed" || grn.status === "Closed") {
return Promise.reject(new Error(`${grn.docNo} has already been confirmed.`))
}
grn.status = "Confirmed"
const createdLayers: CreatedLayer[] = []
const ledgerRefs: number[] = []
for (const line of grn.lines) {
// FR-GRN-06: each line creates a FIFO layer + posts an inbound ledger entry.
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: grn.warehouseId,
binId: line.binId,
batchId: line.batchId,
grnLineId: line.grnLineId,
qty: line.qty,
unitCost: line.unitCost,
userId: grn.createdBy,
sourceDocType: "GRN",
sourceDocId: grn.grnId,
})
createdLayers.push({
layerId: layer.layerId,
itemId: layer.itemId,
warehouseId: layer.warehouseId,
batchId: layer.batchId,
qtyReceived: layer.qtyReceived,
qtyRemaining: layer.qtyRemaining,
unitCost: layer.unitCost,
receiptDate: layer.receiptDate,
})
ledgerRefs.push(ledger.ledgerId)
// FR-PROC-07: accrue the PO line's received quantity as GRNs confirm.
if (line.poLineId && grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
const poLine = po?.lines.find((l) => l.poLineId === line.poLineId)
if (poLine) poLine.qtyReceived = Math.min(poLine.qty, poLine.qtyReceived + line.qty)
}
}
let poStatus: string | null = null
if (grn.poId) {
const po = mockPurchaseOrders.find((p) => p.poId === grn.poId)
if (po) {
const fullyReceived = po.lines.every((l) => l.qtyReceived >= l.qty)
const anyReceived = po.lines.some((l) => l.qtyReceived > 0)
po.status = fullyReceived ? "FullyReceived" : anyReceived ? "PartiallyReceived" : po.status
poStatus = po.status
}
}
const response: ConfirmGrnResponse = {
grnId: grn.grnId,
status: grn.status,
postedAt: new Date().toISOString(),
createdLayers,
ledgerRefs,
poStatus,
}
return mockDelay(response)
return apiRequest<ConfirmGrnResponse>(`/grns/${grnId}/confirm`, { method: "POST", idempotencyKey })
},
/** "Release" makes an on-hold line issuable; "Reject" routes it to a purchase return. */
releaseLine(grnId: number, grnLineId: number, action: ReleaseAction): Promise<ReleaseGrnLineResponse> {
const grn = mockGrns.find((g) => g.grnId === grnId)
const line = grn?.lines.find((l) => l.grnLineId === grnLineId)
if (!grn || !line) return Promise.reject(new Error(`Mock GRN line ${grnLineId} not found`))
line.holdStatus = action === "Release" ? "Available" : "Rejected"
return mockDelay({ grnLineId: line.grnLineId, holdStatus: line.holdStatus })
},
// Draft-only — once confirmed, a GRN has created stock layers/ledger entries
// and is no longer safe to rewrite in place.
update(grnId: number, request: CreateGrnRequest): Promise<Grn> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be edited.`))
}
const referencedPo = request.poId ? mockPurchaseOrders.find((p) => p.poId === request.poId) : undefined
grn.poId = request.poId ?? null
grn.vendorId = referencedPo?.vendorId ?? request.vendorId ?? grn.vendorId
grn.warehouseId = request.warehouseId
grn.lines = request.lines.map((line) => ({
grnLineId: allocateGrnLineId(),
poLineId: line.poLineId ?? null,
itemId: line.itemId,
uomId: line.uomId,
binId: line.binId ?? null,
qty: line.qty,
unitCost: line.unitCost,
receivedValue: Math.round(line.qty * line.unitCost * 100) / 100,
holdStatus: line.holdStatus,
batchId: line.batch ? allocateGrnLineId() : null,
}))
return mockDelay(grn)
},
remove(grnId: number): Promise<void> {
const grn = mockGrns.find((g) => g.grnId === grnId)
if (!grn) return Promise.reject(new Error(`Mock GRN ${grnId} not found`))
if (grn.status !== "Draft") {
return Promise.reject(new Error(`${grn.docNo} is ${grn.status.toLowerCase()} and can no longer be deleted.`))
}
mockGrns.splice(mockGrns.indexOf(grn), 1)
return mockDelay(undefined)
return apiRequest<ReleaseGrnLineResponse>(`/grns/${grnId}/lines/${grnLineId}/release`, {
method: "POST",
body: { action },
})
},
}
+41
View File
@@ -0,0 +1,41 @@
// One typed client method per Item Type endpoint (docs/11-BACKEND-PHASE1.md §2.7; FR-MD-10).
//
// Formerly `variants.ts` / `variantCategoriesApi`. An item type is a dimension NAME
// (Color, Size, Material) and nothing more: no item references one, and there is no value
// resource. `list()` exists to populate the item builder's dropdown — the chosen values are
// encoded into the client-generated SKU and never stored (docs/10 Part C.9).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { CreateItemTypeRequest, ItemType, UpdateItemTypeRequest } from "@/types/master-data"
export interface ListItemTypesParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
sort?: string
}
export const itemTypesApi = {
list(params: ListItemTypesParams = {}): Promise<PagedResponse<ItemType>> {
return apiRequest<PagedResponse<ItemType>>(`/item-types${buildQuery(params)}`)
},
get(itemTypeId: number): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>(`/item-types/${itemTypeId}`)
},
create(request: CreateItemTypeRequest): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>("/item-types", { method: "POST", body: request })
},
/** Renaming does not touch existing items — their SKUs already encode the old value. */
update(itemTypeId: number, request: UpdateItemTypeRequest, ifMatch: string): Promise<ApiResult<ItemType>> {
return apiRequestWithETag<ItemType>(`/item-types/${itemTypeId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(itemTypeId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/item-types/${itemTypeId}/status`, { method: "PATCH", body: { status } })
},
}
+26 -122
View File
@@ -1,25 +1,18 @@
// One typed client method per Item endpoint (docs/11-BACKEND-PHASE1.md §2.1, FR-MD-01/05/08).
// `list` also backs the GRN/PO/Requisition/RFQ item pickers built in earlier sessions.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// `list` also backs the GRN/PO/Requisition/RFQ item pickers.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
Item,
ItemListItem,
ItemReorderSetting,
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
UpdateUomConversionsRequest,
UpdateUomConversionsResponse,
} from "@/types/master-data"
import {
allocateItemId,
bumpItemVersion,
getItemVersion,
initItemVersion,
mockDelay,
mockItems,
} from "@/lib/api/mock-data"
export interface ListItemsParams {
page?: number
@@ -27,139 +20,50 @@ export interface ListItemsParams {
q?: string
status?: EntityStatus
categoryId?: number
subCategoryId?: number
brandId?: number
trackingMode?: TrackingMode
}
function toListItem(item: Item): ItemListItem {
return {
itemId: item.itemId,
sku: item.sku,
name: item.name,
categoryId: item.categoryId,
brandId: item.brandId ?? null,
baseUomId: item.baseUomId,
defaultVendorId: item.defaultVendorId,
itemType: item.itemType,
trackingMode: item.trackingMode,
taxClass: item.taxClass,
status: item.status,
}
}
function skuTaken(sku: string, excludeItemId?: number) {
return mockItems.some((i) => i.itemId !== excludeItemId && i.sku.toLowerCase() === sku.toLowerCase())
sort?: string
}
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockItems
.filter((i) => !params.status || i.status === params.status)
.filter((i) => !params.categoryId || i.categoryId === params.categoryId)
.filter((i) => !params.trackingMode || i.trackingMode === params.trackingMode)
.filter((i) => !term || `${i.sku} ${i.name}`.toLowerCase().includes(term))
.sort((a, b) => a.sku.localeCompare(b.sku))
.map(toListItem)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
},
get(itemId: number): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
return mockDelay({ data: item, etag: String(getItemVersion(itemId)) })
return apiRequestWithETag<Item>(`/items/${itemId}`)
},
/**
* 400 SKU_DUPLICATE if the SKU exists; 422 CONFIG_DISABLED if subCategoryId/brandId is
* sent while that feature is switched off; 422 if the subcategory belongs to a
* different category.
*/
create(request: CreateItemRequest): Promise<ApiResult<Item>> {
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const item: Item = {
itemId: allocateItemId(),
sku,
name: request.name.trim(),
description: request.description?.trim() || null,
categoryId: request.categoryId,
brandId: request.brandId ?? null,
baseUomId: request.baseUomId,
defaultVendorId: request.defaultVendorId ?? null,
itemType: request.itemType,
trackingMode: request.trackingMode,
taxClass: request.taxClass?.trim() || null,
status: "Active",
reorder: [],
conversions: [],
initialQty: request.initialQty ?? null,
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockItems.push(item)
initItemVersion(item.itemId)
return mockDelay({ data: item, etag: "1" })
return apiRequestWithETag<Item>("/items", { method: "POST", body: request })
},
update(itemId: number, request: UpdateItemRequest, ifMatch: string): Promise<ApiResult<Item>> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
if (String(getItemVersion(itemId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The item was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
const sku = request.sku.trim()
if (!sku) return Promise.reject(new Error("SKU is required."))
if (skuTaken(sku, itemId)) {
return Promise.reject(Object.assign(new Error(`SKU "${sku}" already exists.`), { code: "SKU_DUPLICATE" }))
}
item.sku = sku
item.name = request.name.trim()
item.description = request.description?.trim() || null
item.categoryId = request.categoryId
item.brandId = request.brandId ?? null
item.baseUomId = request.baseUomId
item.defaultVendorId = request.defaultVendorId ?? null
item.itemType = request.itemType
item.trackingMode = request.trackingMode
item.taxClass = request.taxClass?.trim() || null
item.updatedAt = new Date().toISOString()
const next = bumpItemVersion(itemId)
return mockDelay({ data: item, etag: String(next) })
return apiRequestWithETag<Item>(`/items/${itemId}`, { method: "PUT", body: request, ifMatch })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(itemId: number, status: EntityStatus): Promise<void> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.status = status
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay(undefined)
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: UpdateItemReorderRequest["settings"] }> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.reorder = request.settings
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ settings: item.reorder })
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
method: "PUT",
body: request,
})
},
updateUomConversions(itemId: number, request: UpdateUomConversionsRequest): Promise<UpdateUomConversionsResponse> {
const item = mockItems.find((i) => i.itemId === itemId)
if (!item) return Promise.reject(new Error(`Mock item ${itemId} not found`))
item.conversions = request.conversions.map((c, i) => ({ conversionId: 1000 + itemId * 10 + i, fromUom: c.fromUom, toUom: c.toUom, factor: c.factor }))
item.updatedAt = new Date().toISOString()
bumpItemVersion(itemId)
return mockDelay({ itemId: item.itemId, baseUomId: item.baseUomId, conversions: item.conversions })
return apiRequest<UpdateUomConversionsResponse>(`/items/${itemId}/uom-conversions`, {
method: "PUT",
body: request,
})
},
}
-833
View File
@@ -1,833 +0,0 @@
// In-memory sample data backing every lib/api/*.ts module — the app has no
// fetch-based backend connection (lib/api-client.ts and lib/auth-token.ts were
// removed). Shapes mirror docs/11-BACKEND-PHASE1.md.
import { Bin, Brand, Category, Item, Uom, Vendor, VariantCategory, Warehouse } from "@/types/master-data"
import { PurchaseOrder, Quotation, Requisition, Rfq, PurchaseReturn } from "@/types/procurement"
import { Grn } from "@/types/grn"
import {
AdjustmentStatus,
CountStatus,
CountType,
LedgerDirection,
LedgerEntry,
ReasonCode,
TransferStatus,
} from "@/types/stock"
export const mockWarehouses: Warehouse[] = [
{ warehouseId: 1, code: "WH-MAIN", name: "Main Warehouse - Negombo" },
{ warehouseId: 2, code: "WH-COLOMBO", name: "Colombo Distribution Center" },
]
export const mockBins: Bin[] = [
{ binId: 1, warehouseId: 1, code: "A-01-01", binType: "Shelf" },
{ binId: 2, warehouseId: 1, code: "A-01-02", binType: "Shelf" },
{ binId: 3, warehouseId: 1, code: "B-02-01", binType: "Pallet" },
{ binId: 4, warehouseId: 2, code: "C-01-01", binType: "Shelf" },
{ binId: 5, warehouseId: 2, code: "C-01-02", binType: "Shelf" },
]
let nextWarehouseId = 3
let nextBinId = 6
export function allocateWarehouseId() {
return nextWarehouseId++
}
export function allocateBinId() {
return nextBinId++
}
export const mockUoms: Uom[] = [
{ uomId: 1, name: "EA" },
{ uomId: 2, name: "Box-12" },
{ uomId: 3, name: "KG" },
]
let nextUomId = 4
export function allocateUomId() {
return nextUomId++
}
export const mockCategories: Category[] = [
{ categoryId: 3, name: "Hardware", parentId: null, createdAt: "2026-06-01T08:00:00Z" },
{ categoryId: 12, name: "Fasteners", parentId: 3, createdAt: "2026-06-01T08:05:00Z" },
{ categoryId: 20, name: "Power Tools", parentId: null, createdAt: "2026-06-02T09:00:00Z" },
]
let nextCategoryId = 21
export function allocateCategoryId() {
return nextCategoryId++
}
export const mockBrands: Brand[] = [
{ brandId: 1, name: "Bosch", createdAt: "2026-06-01T08:00:00Z" },
{ brandId: 2, name: "Makita", createdAt: "2026-06-02T09:00:00Z" },
]
let nextBrandId = 3
export function allocateBrandId() {
return nextBrandId++
}
export const mockVariantCategories: VariantCategory[] = [
{ variantCategoryId: 1, name: "Color", createdAt: "2026-06-01T08:00:00Z" },
{ variantCategoryId: 2, name: "Size", createdAt: "2026-06-01T08:00:00Z" },
]
let nextVariantCategoryId = 3
export function allocateVariantCategoryId() {
return nextVariantCategoryId++
}
export const mockVendors: Vendor[] = [
{
vendorId: 5,
code: "VN-005",
name: "Lanka Steel Traders (Pvt) Ltd",
terms: "NET30",
taxReg: "134567890-7000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
vendorId: 8,
code: "VN-008",
name: "Ceylon Hardware Supplies",
terms: "NET45",
taxReg: "198765432-1000",
currency: "LKR",
status: "Active",
createdAt: "2026-06-05T08:00:00Z",
updatedAt: null,
},
]
// A handful more so the vendors list has something real to paginate/search through.
const extraVendorNames = [
"Colombo Timber & Plywood Co.",
"Kandy Electrical Distributors",
"Galle Packaging Solutions",
"Jaffna Agro Supplies",
"Negombo Fasteners (Pvt) Ltd",
"Kurunegala Paints & Coatings",
"Trinco Marine Hardware",
"Ratnapura Gems & Tools",
]
for (let i = 0; i < extraVendorNames.length; i++) {
const vendorId = 9 + i
mockVendors.push({
vendorId,
code: `VN-${String(vendorId).padStart(3, "0")}`,
name: extraVendorNames[i],
terms: i % 2 === 0 ? "NET30" : "NET60",
taxReg: `1${String(10000000 + vendorId * 137)}-${7000 + i}`,
currency: "LKR",
status: i % 5 === 0 ? "Inactive" : "Active",
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
updatedAt: null,
})
}
let nextVendorId = 9 + extraVendorNames.length
export function allocateVendorId() {
return nextVendorId++
}
// Concurrency token per vendor (stands in for the real backend's xmin/RowVersion
// ETag, docs/11 §1.6) — kept out-of-band since the public Vendor type has no
// version field of its own (it travels as an HTTP ETag header, not a body field).
const mockVendorVersions = new Map<number, number>(mockVendors.map((v) => [v.vendorId, 1]))
export function getVendorVersion(vendorId: number): number {
return mockVendorVersions.get(vendorId) ?? 1
}
export function bumpVendorVersion(vendorId: number): number {
const next = getVendorVersion(vendorId) + 1
mockVendorVersions.set(vendorId, next)
return next
}
export function initVendorVersion(vendorId: number) {
mockVendorVersions.set(vendorId, 1)
}
// Full Item records (docs/11 §2.1). ItemListItem (the list/GRN-picker view) is
// derived from these in lib/api/items.ts, same "full record → mapped summary"
// pattern as mockPurchaseOrders → PurchaseOrderSummary.
export const mockItems: Item[] = [
{
itemId: 1001,
sku: "ITM-1001",
name: "Steel Bolt M8x40",
description: "Grade 8.8 zinc-plated hex bolt",
categoryId: 12,
baseUomId: 1,
defaultVendorId: 5,
itemType: "Stocked",
trackingMode: "Batch",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 }],
conversions: [{ conversionId: 33, fromUom: 2, toUom: 1, factor: 12 }],
createdAt: "2026-06-01T08:00:00Z",
updatedAt: null,
},
{
itemId: 1002,
sku: "ITM-1002",
name: "Steel Nut M8",
description: "Grade 8 zinc-plated hex nut",
categoryId: 12,
baseUomId: 1,
defaultVendorId: 5,
itemType: "Stocked",
trackingMode: "None",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 }],
conversions: [],
createdAt: "2026-06-01T08:05:00Z",
updatedAt: null,
},
{
itemId: 1003,
sku: "ITM-1003",
name: "Cordless Drill 18V",
description: "18V lithium-ion cordless drill/driver, includes charger",
categoryId: 20,
baseUomId: 1,
defaultVendorId: 8,
itemType: "Stocked",
trackingMode: "Serial",
taxClass: "STD",
status: "Active",
reorder: [{ warehouseId: 2, reorderPoint: 15, reorderQty: 20 }],
conversions: [],
createdAt: "2026-06-05T08:10:00Z",
updatedAt: null,
},
]
let nextItemId = 1004
export function allocateItemId() {
return nextItemId++
}
// Concurrency token per item (same out-of-band ETag pattern as mockVendorVersions).
const mockItemVersions = new Map<number, number>(mockItems.map((i) => [i.itemId, 1]))
export function getItemVersion(itemId: number): number {
return mockItemVersions.get(itemId) ?? 1
}
export function bumpItemVersion(itemId: number): number {
const next = getItemVersion(itemId) + 1
mockItemVersions.set(itemId, next)
return next
}
export function initItemVersion(itemId: number) {
mockItemVersions.set(itemId, 1)
}
export const mockPurchaseOrders: PurchaseOrder[] = [
{
poId: 342,
docNo: "PO-2026-00342",
vendorId: 5,
requisitionId: 210,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-07T09:40:00Z",
updatedAt: null,
totals: { subTotal: 112100.0, tax: 20178.0, grandTotal: 132278.0, currency: "LKR" },
lines: [
{ poLineId: 900, itemId: 1001, uomId: 1, warehouseId: 1, qty: 5000, unitPrice: 12.5, tax: 0.18, qtyReceived: 0 },
{ poLineId: 901, itemId: 1002, uomId: 1, warehouseId: 1, qty: 8000, unitPrice: 6.2, tax: 0.18, qtyReceived: 0 },
],
},
{
poId: 350,
docNo: "PO-2026-00350",
vendorId: 8,
requisitionId: null,
status: "PartiallyReceived",
approvalRequired: false,
createdBy: 17,
createdAt: "2026-07-09T09:00:00Z",
updatedAt: "2026-07-10T11:00:00Z",
totals: { subTotal: 22500.0, tax: 4050.0, grandTotal: 26550.0, currency: "LKR" },
lines: [
{ poLineId: 910, itemId: 1003, uomId: 1, warehouseId: 2, qty: 50, unitPrice: 450.0, tax: 0.18, qtyReceived: 20 },
],
},
]
let nextPoId = 351
export function allocatePoId() {
return nextPoId++
}
// Concurrency token per PO (same out-of-band ETag pattern as mockVendorVersions,
// docs/11 §1.6) — backs PUT /purchase-orders/{poId}'s If-Match (FR-PROC-05, Option B).
const mockPoVersions = new Map<number, number>(mockPurchaseOrders.map((p) => [p.poId, 1]))
export function getPoVersion(poId: number): number {
return mockPoVersions.get(poId) ?? 1
}
export function bumpPoVersion(poId: number): number {
const next = getPoVersion(poId) + 1
mockPoVersions.set(poId, next)
return next
}
export function initPoVersion(poId: number) {
mockPoVersions.set(poId, 1)
}
export const mockGrns: Grn[] = [
{
grnId: 780,
docNo: "GRN-2026-00780",
poId: 342,
vendorId: 5,
warehouseId: 1,
status: "Draft",
createdBy: 17,
createdAt: "2026-07-11T10:00:00Z",
lines: [
{
grnLineId: 1300,
poLineId: 900,
itemId: 1001,
uomId: 1,
binId: 1,
qty: 5000,
unitCost: 12.5,
receivedValue: 62500.0,
holdStatus: "OnHold",
batchId: 410,
},
],
},
{
grnId: 781,
docNo: "GRN-2026-00781",
poId: null,
vendorId: 8,
warehouseId: 2,
status: "Confirmed",
createdBy: 17,
createdAt: "2026-07-10T14:30:00Z",
lines: [
{
grnLineId: 1310,
poLineId: null,
itemId: 1003,
uomId: 1,
binId: 4,
qty: 5,
unitCost: 450.0,
receivedValue: 2250.0,
holdStatus: "Available",
batchId: null,
},
],
},
]
// A handful more so the list screen's pagination/search/filter controls have
// something real to page through (10 items total across statuses/warehouses).
const extraStatuses: Grn["status"][] = ["Draft", "Confirmed", "Closed", "Confirmed", "Draft", "Confirmed", "Closed", "Draft"]
for (let i = 0; i < extraStatuses.length; i++) {
const grnId = 782 + i
const warehouseId = i % 2 === 0 ? 1 : 2
const vendorId = i % 2 === 0 ? 5 : 8
const itemId = i % 2 === 0 ? 1001 : 1003
const status = extraStatuses[i]
mockGrns.push({
grnId,
docNo: `GRN-2026-${String(grnId).padStart(5, "0")}`,
poId: i % 3 === 0 ? null : 342,
vendorId,
warehouseId,
status,
createdBy: 17,
createdAt: new Date(Date.UTC(2026, 6, 1 + i, 9, 0, 0)).toISOString(),
lines: [
{
grnLineId: 2000 + i,
poLineId: i % 3 === 0 ? null : 900,
itemId,
uomId: 1,
binId: warehouseId === 1 ? 1 : 4,
qty: 100 * (i + 1),
unitCost: 10 + i,
receivedValue: 100 * (i + 1) * (10 + i),
holdStatus: status === "Draft" ? "OnHold" : "Available",
batchId: null,
},
],
})
}
let nextGrnId = 782 + extraStatuses.length
let nextGrnLineId = 2000 + extraStatuses.length
export function allocateGrnId() {
return nextGrnId++
}
export function allocateGrnLineId() {
return nextGrnLineId++
}
/** Small delay so loading states are visible when reviewing the UI. */
export function mockDelay<T>(value: T, ms = 300): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms))
}
// ============================================================================
// Stock Core (FIFO layers + immutable ledger) — docs/10 Part C.5, FR-STK-01..04.
// GRN confirm and every stock transaction below post through these helpers so
// Stock Enquiry / Ledger / Valuation reflect what actually happened this session.
// ============================================================================
export interface MockStockLayer {
layerId: number
itemId: number
warehouseId: number
batchId: number | null
serialId: number | null
grnLineId: number | null
qtyReceived: number
qtyRemaining: number
unitCost: number
receiptDate: string
}
export const mockStockLayers: MockStockLayer[] = []
export const mockStockLedger: LedgerEntry[] = []
let nextLayerId = 9001
let nextLedgerId = 55010
export function allocateLayerId() {
return nextLayerId++
}
export function allocateLedgerId() {
return nextLedgerId++
}
function round2(n: number) {
return Math.round(n * 100) / 100
}
function latestRunningBalance(itemId: number, warehouseId: number): number {
for (let i = mockStockLedger.length - 1; i >= 0; i--) {
const entry = mockStockLedger[i]
if (entry.itemId === itemId && entry.warehouseId === warehouseId) return entry.runningBalance
}
return 0
}
export function postLedgerEntry(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
userId: number
direction: LedgerDirection
qtyBase: number
unitCost: number
sourceDocType: string
sourceDocId: number
}): LedgerEntry {
const prior = latestRunningBalance(input.itemId, input.warehouseId)
const delta = input.direction === "In" ? input.qtyBase : -input.qtyBase
const entry: LedgerEntry = {
ledgerId: allocateLedgerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId ?? null,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
direction: input.direction,
qtyBase: input.qtyBase,
unitCost: input.unitCost,
value: round2(input.qtyBase * input.unitCost),
runningBalance: round2(prior + delta),
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
userId: input.userId,
createdAt: new Date().toISOString(),
}
mockStockLedger.push(entry)
return entry
}
/** Creates a FIFO layer + posts the matching inbound ledger entry (FR-GRN-06 / FR-STK-02). */
export function receiveLayer(input: {
itemId: number
warehouseId: number
binId?: number | null
batchId?: number | null
serialId?: number | null
grnLineId?: number | null
qty: number
unitCost: number
userId: number
sourceDocType: string
sourceDocId: number
}): { layer: MockStockLayer; ledger: LedgerEntry } {
const layer: MockStockLayer = {
layerId: allocateLayerId(),
itemId: input.itemId,
warehouseId: input.warehouseId,
batchId: input.batchId ?? null,
serialId: input.serialId ?? null,
grnLineId: input.grnLineId ?? null,
qtyReceived: input.qty,
qtyRemaining: input.qty,
unitCost: input.unitCost,
receiptDate: new Date().toISOString(),
}
mockStockLayers.push(layer)
const ledger = postLedgerEntry({
itemId: input.itemId,
warehouseId: input.warehouseId,
binId: input.binId,
batchId: input.batchId,
serialId: input.serialId,
userId: input.userId,
direction: "In",
qtyBase: input.qty,
unitCost: input.unitCost,
sourceDocType: input.sourceDocType,
sourceDocId: input.sourceDocId,
})
return { layer, ledger }
}
/** 409 STOCK_NEGATIVE_BLOCKED (docs/11 §7) — thrown by consumeFifo when available < requested. */
export class StockNegativeError extends Error {
code = "STOCK_NEGATIVE_BLOCKED"
constructor(itemId: number, warehouseId: number) {
super(`Not enough available stock for item #${itemId} at warehouse #${warehouseId}.`)
}
}
/** A layer is unavailable while its originating GRN line is still on hold/rejected (docs/10 C.9). */
function isLayerOnHold(layer: MockStockLayer): boolean {
if (!layer.grnLineId) return false
for (const grn of mockGrns) {
const line = grn.lines.find((l) => l.grnLineId === layer.grnLineId)
if (line) return line.holdStatus === "OnHold" || line.holdStatus === "Rejected"
}
return false
}
/** Consumes the oldest open (non-held) layers first (FR-STK-03); throws StockNegativeError if insufficient. */
export function consumeFifo(
itemId: number,
warehouseId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number }[] {
let remaining = qty
const consumed: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const candidates = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0 && !isLayerOnHold(l))
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
for (const layer of candidates) {
if (remaining <= 0) break
const take = Math.min(layer.qtyRemaining, remaining)
layer.qtyRemaining = round2(layer.qtyRemaining - take)
remaining = round2(remaining - take)
consumed.push({ layerId: layer.layerId, qtyConsumed: take, unitCost: layer.unitCost })
}
if (remaining > 0.0001) throw new StockNegativeError(itemId, warehouseId)
return consumed
}
/** "Last cost" for an adjustment increase (FR-STK-07) when no more specific cost is supplied. */
export function lastKnownCost(itemId: number, warehouseId: number): number {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
.sort((a, b) => new Date(b.receiptDate).getTime() - new Date(a.receiptDate).getTime())
return layers[0]?.unitCost ?? 10
}
/**
* Consumes stock for a Purchase Return against the specific layer its GRN line created
* (FR-PROC-08) — deliberately not routed through consumeFifo: a return disposes of the
* exact received batch (often On-hold/Rejected, which consumeFifo's isLayerOnHold filter
* would otherwise skip), not just "the oldest open layer for this item/warehouse".
* Throws StockNegativeError (409 STOCK_NEGATIVE_BLOCKED, docs/11 §3.4) if the return
* qty exceeds what remains on that layer.
*/
export function consumeLayerByGrnLine(
grnLineId: number,
qty: number
): { layerId: number; qtyConsumed: number; unitCost: number; itemId: number; warehouseId: number } {
const layer = mockStockLayers.find((l) => l.grnLineId === grnLineId)
if (!layer || layer.qtyRemaining < qty) {
const itemId = layer?.itemId ?? 0
const warehouseId = layer?.warehouseId ?? 0
throw new StockNegativeError(itemId, warehouseId)
}
layer.qtyRemaining = round2(layer.qtyRemaining - qty)
return { layerId: layer.layerId, qtyConsumed: qty, unitCost: layer.unitCost, itemId: layer.itemId, warehouseId: layer.warehouseId }
}
export function computeOnHand(itemId: number, warehouseId: number) {
const layers = mockStockLayers.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId)
const onHand = round2(layers.reduce((sum, l) => sum + l.qtyRemaining, 0))
const onHold = round2(layers.filter(isLayerOnHold).reduce((sum, l) => sum + l.qtyRemaining, 0))
const inTransit = round2(
mockStockTransfers
.filter((t) => t.status === "InTransit" && t.destWarehouseId === warehouseId)
.flatMap((t) => t.lines)
.filter((l) => l.itemId === itemId)
.reduce((sum, l) => sum + l.qty, 0)
)
const reserved = 0
const available = Math.max(0, round2(onHand - onHold - reserved))
return { onHand, onHold, inTransit, reserved, available }
}
/** Every item/warehouse combination that currently has (or ever had) a layer — drives the Enquiry screen. */
export function knownStockKeys(): { itemId: number; warehouseId: number }[] {
const seen = new Map<string, { itemId: number; warehouseId: number }>()
for (const layer of mockStockLayers) {
seen.set(`${layer.itemId}:${layer.warehouseId}`, { itemId: layer.itemId, warehouseId: layer.warehouseId })
}
return [...seen.values()]
}
// --- Reference data (docs/11 §6) --------------------------------------------------
export interface MockItemReorder {
itemId: number
warehouseId: number
reorderPoint: number
reorderQty: number
}
export const mockItemReorders: MockItemReorder[] = [
{ itemId: 1001, warehouseId: 1, reorderPoint: 2500, reorderQty: 5000 },
{ itemId: 1002, warehouseId: 1, reorderPoint: 5000, reorderQty: 8000 },
{ itemId: 1003, warehouseId: 2, reorderPoint: 15, reorderQty: 20 },
]
export const mockReasonCodes: ReasonCode[] = [
{ reasonCodeId: 1, code: "DMG", description: "Damage", context: "Adjustment" },
{ reasonCodeId: 2, code: "LOSS", description: "Theft/Loss", context: "Adjustment" },
{ reasonCodeId: 3, code: "CNTVAR", description: "Count Variance", context: "Adjustment" },
{ reasonCodeId: 4, code: "EXPWO", description: "Expiry Write-off", context: "Adjustment" },
{ reasonCodeId: 5, code: "SYSCORR", description: "System Correction", context: "Adjustment" },
{ reasonCodeId: 22, code: "QREJ", description: "Quality Reject", context: "Return" },
]
// --- Seed some prior receipts so Enquiry/Ledger/Valuation aren't empty on first load ---
receiveLayer({
itemId: 1001, warehouseId: 1, binId: 1, batchId: 411, qty: 3000, unitCost: 12.5,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1002, warehouseId: 1, binId: 2, qty: 6000, unitCost: 6.2,
userId: 17, sourceDocType: "GRN", sourceDocId: 779,
})
receiveLayer({
itemId: 1003, warehouseId: 2, binId: 4, qty: 20, unitCost: 450,
userId: 17, sourceDocType: "GRN", sourceDocId: 781,
})
// ============================================================================
// Transfers (FR-STK-05/06) — create → dispatch (consume src) → receive (create dest).
// ============================================================================
export interface MockTransferLine {
transferLineId: number
itemId: number
srcBinId: number | null
destBinId: number | null
batchId: number | null
qty: number
/** Recorded on dispatch so receive() can create cost-preserving destination layers (FR-STK-06). */
dispatchedChunks: { layerId: number; qtyConsumed: number; unitCost: number }[]
}
export interface MockStockTransfer {
transferId: number
docNo: string
srcWarehouseId: number
destWarehouseId: number
status: TransferStatus
createdBy: number
createdAt: string
lines: MockTransferLine[]
}
export const mockStockTransfers: MockStockTransfer[] = []
let nextTransferId = 55
let nextTransferLineId = 300
export function allocateTransferId() {
return nextTransferId++
}
export function allocateTransferLineId() {
return nextTransferLineId++
}
// ============================================================================
// Adjustments (FR-STK-07) — auto-post on creation.
// ============================================================================
export interface MockAdjustmentLine {
adjLineId: number
itemId: number
binId: number | null
batchId: number | null
qtyDelta: number
}
export interface MockStockAdjustment {
adjustmentId: number
docNo: string
warehouseId: number
reasonCodeId: number
status: AdjustmentStatus
createdBy: number
createdAt: string
lines: MockAdjustmentLine[]
ledgerRefs: number[]
}
export const mockStockAdjustments: MockStockAdjustment[] = []
let nextAdjustmentId = 77
let nextAdjLineId = 210
export function allocateAdjustmentId() {
return nextAdjustmentId++
}
export function allocateAdjLineId() {
return nextAdjLineId++
}
// ============================================================================
// Counts (FR-STK-08) — snapshot system qty → enter counted qty → post variance.
// ============================================================================
export interface MockCountLine {
countLineId: number
itemId: number
binId: number | null
systemQty: number
countedQty: number | null
variance: number | null
}
export interface MockStockCount {
countId: number
docNo: string
warehouseId: number
countType: CountType
status: CountStatus
createdBy: number
createdAt: string
lines: MockCountLine[]
}
export const mockStockCounts: MockStockCount[] = []
let nextCountId = 30
let nextCountLineId = 400
export function allocateCountId() {
return nextCountId++
}
export function allocateCountLineId() {
return nextCountLineId++
}
// ============================================================================
// Procurement (FR-PROC-01..09) — Requisition → RFQ → Quotations → PO → Return.
// No Procurement backend exists yet; same frontend-only posture as GRN/Stock.
// ============================================================================
export const mockRequisitions: Requisition[] = [
// Seeded to match mockPurchaseOrders[0].requisitionId (PO-2026-00342 was raised
// against this requisition) so the two screens cross-reference consistently.
{
requisitionId: 210,
docNo: "PR-2026-00210",
status: "Submitted",
requestedBy: 17,
createdAt: "2026-07-06T08:30:00Z",
lines: [
{ reqLineId: 501, itemId: 1001, qty: 5000, requiredBy: "2026-07-20" },
{ reqLineId: 502, itemId: 1002, qty: 8000, requiredBy: "2026-07-20" },
],
},
]
let nextRequisitionId = 211
let nextReqLineId = 503
export function allocateRequisitionId() {
return nextRequisitionId++
}
export function allocateReqLineId() {
return nextReqLineId++
}
export const mockRfqs: Rfq[] = []
let nextRfqId = 89
let nextRfqLineId = 703
export function allocateRfqId() {
return nextRfqId++
}
export function allocateRfqLineId() {
return nextRfqLineId++
}
export const mockQuotations: Quotation[] = []
let nextQuotationId = 141
export function allocateQuotationId() {
return nextQuotationId++
}
export const mockPurchaseReturns: PurchaseReturn[] = []
let nextPurchaseReturnId = 61
let nextPurchaseReturnLineId = 121
export function allocatePurchaseReturnId() {
return nextPurchaseReturnId++
}
export function allocatePurchaseReturnLineId() {
return nextPurchaseReturnLineId++
}
@@ -0,0 +1,25 @@
// Product configuration (docs/11-BACKEND-PHASE1.md §2.8; FR-MD-11). Singleton — no id.
//
// `subcategoriesEnabled`/`brandsEnabled` are enforced server-side: an item write carrying
// a gated field while its flag is off returns 422 CONFIG_DISABLED. `itemTypesEnabled` is
// ADVISORY — items hold no item-type reference, so there is nothing for the server to
// reject; this frontend is what honours it by hiding the builder's type section.
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
export const productConfigApi = {
get(): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config")
},
/** All three flags are required; a partial body is a 400, never a silent disable. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
}
/** Read-only helper for screens that only need the flags. */
export function productConfig(): Promise<ProductConfig> {
return apiRequest<ProductConfig>("/product-config")
}
+18 -119
View File
@@ -1,6 +1,6 @@
// One typed client method per Purchase Order endpoint (docs/11-BACKEND-PHASE1.md §3.3,
// FR-PROC-03..07). `get`/`list` also back the GRN "against a PO" picker.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CancelPurchaseOrderRequest,
@@ -10,14 +10,6 @@ import {
PurchaseOrderSummary,
UpdatePurchaseOrderRequest,
} from "@/types/procurement"
import {
allocatePoId,
bumpPoVersion,
getPoVersion,
initPoVersion,
mockDelay,
mockPurchaseOrders,
} from "@/lib/api/mock-data"
export interface ListPurchaseOrdersParams {
page?: number
@@ -25,138 +17,45 @@ export interface ListPurchaseOrdersParams {
q?: string
status?: PurchaseOrderStatus
vendorId?: number
sort?: string
}
/** FR-PROC-05 (Option B): freely editable while open — not once fully received/closed/cancelled. */
/** Editable while open (FR-PROC-05, Option B). The server is authoritative — it returns
* 409 PO_NOT_EDITABLE regardless — this only drives UI affordances. */
export function isPoEditable(status: PurchaseOrderStatus): boolean {
return status !== "FullyReceived" && status !== "Closed" && status !== "Cancelled"
}
function computeTotals(lines: CreatePurchaseOrderRequest["lines"], currency = "LKR") {
const subTotal = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0) * 100) / 100
const tax = Math.round(lines.reduce((sum, l) => sum + l.qty * l.unitPrice * l.tax, 0) * 100) / 100
return { subTotal, tax, grandTotal: Math.round((subTotal + tax) * 100) / 100, currency }
}
export const purchaseOrdersApi = {
list(params: ListPurchaseOrdersParams = {}): Promise<PagedResponse<PurchaseOrderSummary>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockPurchaseOrders
.filter((po) => !params.status || po.status === params.status)
.filter((po) => !params.vendorId || po.vendorId === params.vendorId)
.filter((po) => !term || `${po.docNo} ${po.vendorId}`.toLowerCase().includes(term))
.sort((a, b) => b.poId - a.poId)
.map(
(po): PurchaseOrderSummary => ({
poId: po.poId,
docNo: po.docNo,
vendorId: po.vendorId,
status: po.status,
approvalRequired: po.approvalRequired,
createdAt: po.createdAt,
totals: po.totals,
})
)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<PurchaseOrderSummary>>(`/purchase-orders${buildQuery(params)}`)
},
get(poId: number): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay(po)
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}`)
},
getWithETag(poId: number): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
return mockDelay({ data: po, etag: String(getPoVersion(poId)) })
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
},
/** Auto-approved on creation in Phase 1 (approvalRequired defaults false, FR-PROC-04). */
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const poId = allocatePoId()
// FR-PROC-04: approvalRequired defaults false → auto-approved on creation.
const po: PurchaseOrder = {
poId,
docNo: `PO-2026-${String(poId).padStart(5, "0")}`,
vendorId: request.vendorId,
requisitionId: request.requisitionId ?? null,
status: "Approved",
approvalRequired: false,
createdBy: 17,
createdAt: new Date().toISOString(),
updatedAt: null,
totals: computeTotals(request.lines),
lines: request.lines.map((l, i) => ({
poLineId: 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: 0,
})),
}
mockPurchaseOrders.push(po)
initPoVersion(poId)
return mockDelay({ data: po, etag: "1" })
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
},
/** 409 PO_NOT_EDITABLE once fully received/closed/cancelled. */
update(poId: number, request: UpdatePurchaseOrderRequest, ifMatch: string): Promise<ApiResult<PurchaseOrder>> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (!isPoEditable(po.status)) {
return Promise.reject(Object.assign(new Error(`${po.docNo} is ${po.status} and can no longer be edited.`), { code: "PO_NOT_EDITABLE" }))
}
if (String(getPoVersion(poId)) !== ifMatch) {
return Promise.reject(Object.assign(new Error("The purchase order was modified by another request."), { code: "CONCURRENCY_CONFLICT" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const priorQtyReceived = new Map(po.lines.map((l) => [l.poLineId, l.qtyReceived]))
po.vendorId = request.vendorId
po.requisitionId = request.requisitionId ?? null
po.totals = computeTotals(request.lines, po.totals.currency)
po.lines = request.lines.map((l, i) => {
// Preserve qtyReceived for lines that already existed (edit-while-open must not erase receipt progress).
const existingLineId = po.lines[i]?.poLineId
return {
poLineId: existingLineId ?? 900 + poId * 10 + i,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: l.qty,
unitPrice: l.unitPrice,
tax: l.tax,
qtyReceived: existingLineId ? (priorQtyReceived.get(existingLineId) ?? 0) : 0,
}
})
po.updatedAt = new Date().toISOString()
const next = bumpPoVersion(poId)
return mockDelay({ data: po, etag: String(next) })
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
},
/** No-op in Phase 1 unless approvals are enabled (FR-PROC-04). */
approve(poId: number): Promise<PurchaseOrder> {
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
},
/** 409 if any receipt exists against the PO. */
cancel(poId: number, request: CancelPurchaseOrderRequest): Promise<PurchaseOrder> {
const po = mockPurchaseOrders.find((p) => p.poId === poId)
if (!po) return Promise.reject(new Error(`Mock purchase order ${poId} not found`))
if (po.lines.some((l) => l.qtyReceived > 0)) {
return Promise.reject(new Error(`${po.docNo} has receipts against it and can no longer be cancelled.`))
}
if (!request.reason.trim()) return Promise.reject(new Error("A cancellation reason is required."))
po.status = "Cancelled"
po.updatedAt = new Date().toISOString()
bumpPoVersion(poId)
return mockDelay(po)
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/cancel`, { method: "POST", body: request })
},
}
+18 -79
View File
@@ -1,93 +1,32 @@
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4, FR-PROC-08).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// One typed client method per Purchase Return endpoint (docs/11-BACKEND-PHASE1.md §3.4,
// FR-PROC-08). Auto-posts an outbound FIFO movement on create.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreatePurchaseReturnRequest, PurchaseReturn, PurchaseReturnSummary } from "@/types/procurement"
import {
allocatePurchaseReturnId,
allocatePurchaseReturnLineId,
consumeLayerByGrnLine,
mockDelay,
mockPurchaseReturns,
postLedgerEntry,
} from "@/lib/api/mock-data"
function toSummary(r: PurchaseReturn): PurchaseReturnSummary {
return {
returnId: r.returnId,
docNo: r.docNo,
vendorId: r.vendorId,
warehouseId: r.warehouseId,
reasonCodeId: r.reasonCodeId,
status: r.status,
createdAt: r.createdAt,
}
export interface ListPurchaseReturnsParams {
page?: number
pageSize?: number
q?: string
vendorId?: number
warehouseId?: number
sort?: string
}
export const purchaseReturnsApi = {
list(): Promise<PagedResponse<PurchaseReturnSummary>> {
const items = [...mockPurchaseReturns].sort((a, b) => b.returnId - a.returnId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(params: ListPurchaseReturnsParams = {}): Promise<PagedResponse<PurchaseReturnSummary>> {
return apiRequest<PagedResponse<PurchaseReturnSummary>>(`/purchase-returns${buildQuery(params)}`)
},
get(returnId: number): Promise<PurchaseReturn> {
const r = mockPurchaseReturns.find((x) => x.returnId === returnId)
if (!r) return Promise.reject(new Error(`Mock purchase return ${returnId} not found`))
return mockDelay(r)
return apiRequest<PurchaseReturn>(`/purchase-returns/${returnId}`)
},
/**
* 400 REASON_CODE_REQUIRED without a reason; 422 if it is not a Return-context reason;
* 409 STOCK_NEGATIVE_BLOCKED if the return exceeds available stock.
*/
create(request: CreatePurchaseReturnRequest): Promise<PurchaseReturn> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for returns."), { code: "REASON_CODE_REQUIRED" }))
}
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const returnId = allocatePurchaseReturnId()
const ledgerRefs: number[] = []
const lines: PurchaseReturn["lines"] = []
try {
for (const line of request.lines) {
// FR-PROC-08: consumes the exact layer the GRN line created; throws
// StockNegativeError (409 STOCK_NEGATIVE_BLOCKED) if qty exceeds it.
const chunk = consumeLayerByGrnLine(line.grnLineId, line.qty)
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: chunk.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "PurchaseReturn",
sourceDocId: returnId,
})
ledgerRefs.push(ledger.ledgerId)
lines.push({
returnLineId: allocatePurchaseReturnLineId(),
grnLineId: line.grnLineId,
itemId: line.itemId,
qty: line.qty,
})
}
} catch (err) {
return Promise.reject(err)
}
const purchaseReturn: PurchaseReturn = {
returnId,
docNo: `PRET-2026-${String(returnId).padStart(5, "0")}`,
vendorId: request.vendorId,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockPurchaseReturns.push(purchaseReturn)
return mockDelay(purchaseReturn)
return apiRequest<PurchaseReturn>("/purchase-returns", { method: "POST", body: request })
},
}
+10 -8
View File
@@ -1,15 +1,17 @@
// One typed client method for reference data (docs/11-BACKEND-PHASE1.md §6).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { ReasonCode, ReasonCodeContext } from "@/types/stock"
import { mockDelay, mockReasonCodes } from "@/lib/api/mock-data"
export interface ListReasonCodesParams {
context?: ReasonCodeContext
page?: number
pageSize?: number
q?: string
}
export const reasonCodesApi = {
list(context?: ReasonCodeContext): Promise<PagedResponse<ReasonCode>> {
const items = mockReasonCodes.filter((r) => !context || r.context === context)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(context?: ReasonCodeContext, params: Omit<ListReasonCodesParams, "context"> = {}): Promise<PagedResponse<ReasonCode>> {
return apiRequest<PagedResponse<ReasonCode>>(`/reason-codes${buildQuery({ context, ...params })}`)
},
}
+8 -51
View File
@@ -1,74 +1,31 @@
// One typed client method per Requisition endpoint (docs/11-BACKEND-PHASE1.md §3.1, FR-PROC-01).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateRequisitionRequest, Requisition, RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { allocateReqLineId, allocateRequisitionId, mockDelay, mockRequisitions } from "@/lib/api/mock-data"
export interface ListRequisitionsParams {
page?: number
pageSize?: number
q?: string
status?: RequisitionStatus
}
function toSummary(r: Requisition): RequisitionSummary {
return {
requisitionId: r.requisitionId,
docNo: r.docNo,
status: r.status,
requestedBy: r.requestedBy,
createdAt: r.createdAt,
lineCount: r.lines.length,
}
sort?: string
}
export const requisitionsApi = {
list(params: ListRequisitionsParams = {}): Promise<PagedResponse<RequisitionSummary>> {
const filtered = mockRequisitions
.filter((r) => !params.status || r.status === params.status)
.map(toSummary)
.sort((a, b) => b.requisitionId - a.requisitionId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<RequisitionSummary>>(`/requisitions${buildQuery(params)}`)
},
get(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
return mockDelay(r)
return apiRequest<Requisition>(`/requisitions/${requisitionId}`)
},
/** `requestedBy` is stamped from the session, never posted. */
create(request: CreateRequisitionRequest): Promise<Requisition> {
if (request.lines.length === 0) {
return Promise.reject(new Error("A requisition needs at least one line."))
}
const requisition: Requisition = {
requisitionId: allocateRequisitionId(),
docNo: "",
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ reqLineId: allocateReqLineId(), itemId: l.itemId, qty: l.qty, requiredBy: l.requiredBy })),
}
requisition.docNo = `PR-2026-${String(requisition.requisitionId).padStart(5, "0")}`
mockRequisitions.push(requisition)
return mockDelay(requisition)
return apiRequest<Requisition>("/requisitions", { method: "POST", body: request })
},
submit(requisitionId: number): Promise<Requisition> {
const r = mockRequisitions.find((x) => x.requisitionId === requisitionId)
if (!r) return Promise.reject(new Error(`Mock requisition ${requisitionId} not found`))
if (r.status !== "Draft") {
return Promise.reject(new Error(`${r.docNo} has already been submitted.`))
}
r.status = "Submitted"
return mockDelay(r)
return apiRequest<Requisition>(`/requisitions/${requisitionId}/submit`, { method: "POST" })
},
}
+19 -72
View File
@@ -1,5 +1,8 @@
// One typed client method per RFQ/Quotation endpoint (docs/11-BACKEND-PHASE1.md §3.2, FR-PROC-02).
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
//
// Note `vendorIds` is validated on create but not persisted — there is no RFQ↔vendor link
// in the model, so an Rfq comes back without them; quotations reference vendors directly.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateQuotationRequest,
@@ -7,94 +10,38 @@ import {
Quotation,
Rfq,
RfqComparison,
RfqComparisonLine,
RfqStatus,
RfqSummary,
} from "@/types/procurement"
import {
allocateQuotationId,
allocateRfqId,
allocateRfqLineId,
mockDelay,
mockQuotations,
mockRfqs,
} from "@/lib/api/mock-data"
function toSummary(r: Rfq): RfqSummary {
return {
rfqId: r.rfqId,
docNo: r.docNo,
requisitionId: r.requisitionId,
status: r.status,
vendorIds: r.vendorIds,
createdAt: r.createdAt,
}
export interface ListRfqsParams {
page?: number
pageSize?: number
q?: string
status?: RfqStatus
sort?: string
}
export const rfqsApi = {
list(): Promise<PagedResponse<RfqSummary>> {
const items = [...mockRfqs].sort((a, b) => b.rfqId - a.rfqId).map(toSummary)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
list(params: ListRfqsParams = {}): Promise<PagedResponse<RfqSummary>> {
return apiRequest<PagedResponse<RfqSummary>>(`/rfqs${buildQuery(params)}`)
},
get(rfqId: number): Promise<Rfq> {
const r = mockRfqs.find((x) => x.rfqId === rfqId)
if (!r) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
return mockDelay(r)
return apiRequest<Rfq>(`/rfqs/${rfqId}`)
},
create(request: CreateRfqRequest): Promise<Rfq> {
if (request.vendorIds.length === 0) return Promise.reject(new Error("Select at least one vendor."))
if (request.lines.length === 0) return Promise.reject(new Error("Add at least one line."))
const rfq: Rfq = {
rfqId: allocateRfqId(),
docNo: "",
requisitionId: request.requisitionId ?? null,
status: "Open",
vendorIds: request.vendorIds,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({ rfqLineId: allocateRfqLineId(), itemId: l.itemId, qty: l.qty })),
}
rfq.docNo = `RFQ-2026-${String(rfq.rfqId).padStart(5, "0")}`
mockRfqs.push(rfq)
return mockDelay(rfq)
return apiRequest<Rfq>("/rfqs", { method: "POST", body: request })
},
/** One quotation per vendor per RFQ; a duplicate returns 409. */
addQuotation(rfqId: number, request: CreateQuotationRequest): Promise<Quotation> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
if (!rfq.vendorIds.includes(request.vendorId)) {
return Promise.reject(new Error("This vendor was not invited to the RFQ."))
}
const quotation: Quotation = {
quotationId: allocateQuotationId(),
rfqId,
vendorId: request.vendorId,
createdAt: new Date().toISOString(),
lines: request.lines,
}
mockQuotations.push(quotation)
return mockDelay(quotation)
return apiRequest<Quotation>(`/rfqs/${rfqId}/quotations`, { method: "POST", body: request })
},
/** Server-computed vendor-by-line price matrix. */
comparison(rfqId: number): Promise<RfqComparison> {
const rfq = mockRfqs.find((x) => x.rfqId === rfqId)
if (!rfq) return Promise.reject(new Error(`Mock RFQ ${rfqId} not found`))
const quotations = mockQuotations.filter((q) => q.rfqId === rfqId)
const lines: RfqComparisonLine[] = rfq.lines.map((rfqLine) => ({
itemId: rfqLine.itemId,
qty: rfqLine.qty,
cells: quotations
.map((q) => {
const line = q.lines.find((l) => l.itemId === rfqLine.itemId)
return line ? { vendorId: q.vendorId, unitPrice: line.unitPrice, leadDays: line.leadDays } : null
})
.filter((c): c is { vendorId: number; unitPrice: number; leadDays: number } => c !== null),
}))
return mockDelay({ rfqId, vendorIds: rfq.vendorIds, lines })
return apiRequest<RfqComparison>(`/rfqs/${rfqId}/comparison`)
},
}
+13 -105
View File
@@ -1,124 +1,32 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// Auto-posts on create with a mandatory reason code (FR-STK-07).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
export interface ListAdjustmentsParams {
page?: number
pageSize?: number
q?: string
warehouseId?: number
}
function toSummary(a: (typeof mockStockAdjustments)[number]): StockAdjustmentSummary {
return {
adjustmentId: a.adjustmentId,
docNo: a.docNo,
warehouseId: a.warehouseId,
reasonCodeId: a.reasonCodeId,
status: a.status,
createdAt: a.createdAt,
}
reasonCodeId?: number
sort?: string
}
export const stockAdjustmentsApi = {
list(params: ListAdjustmentsParams = {}): Promise<PagedResponse<StockAdjustmentSummary>> {
const filtered = mockStockAdjustments
.filter((a) => !params.warehouseId || a.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.adjustmentId - a.adjustmentId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockAdjustmentSummary>>(`/stock-adjustments${buildQuery(params)}`)
},
get(adjustmentId: number): Promise<StockAdjustment> {
const a = mockStockAdjustments.find((x) => x.adjustmentId === adjustmentId)
if (!a) return Promise.reject(new Error(`Mock adjustment ${adjustmentId} not found`))
return mockDelay(a)
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}`)
},
/**
* 400 REASON_CODE_REQUIRED without a reason; 422 if it is not an Adjustment-context
* reason or qtyDelta is 0; 409 STOCK_NEGATIVE_BLOCKED if a decrease exceeds available.
*/
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
if (!request.reasonCodeId) {
return Promise.reject(Object.assign(new Error("A reason code is required for adjustments."), { code: "REASON_CODE_REQUIRED" }))
}
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const lines: StockAdjustment["lines"] = []
try {
for (const line of request.lines) {
const adjLineId = allocateAdjLineId()
lines.push({ adjLineId, itemId: line.itemId, binId: line.binId ?? null, batchId: line.batchId ?? null, qtyDelta: line.qtyDelta })
if (line.qtyDelta > 0) {
// FR-STK-07: increase creates a layer at the last known cost.
const unitCost = lastKnownCost(line.itemId, request.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
qty: line.qtyDelta,
unitCost,
userId: 17,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
} else if (line.qtyDelta < 0) {
// Decrease consumes FIFO layers (409 STOCK_NEGATIVE_BLOCKED if insufficient).
const chunks = consumeFifo(line.itemId, request.warehouseId, Math.abs(line.qtyDelta))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: request.warehouseId,
binId: line.binId,
batchId: line.batchId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Adjustment",
sourceDocId: adjustmentId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
} catch (err) {
return Promise.reject(err)
}
const adjustment = {
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
reasonCodeId: request.reasonCodeId,
status: "Posted" as AdjustmentStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines,
ledgerRefs,
}
mockStockAdjustments.push(adjustment)
return mockDelay(adjustment)
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
}
+13 -132
View File
@@ -1,164 +1,45 @@
// One typed client method per count endpoint (docs/11-BACKEND-PHASE1.md §5.6).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// create (snapshots system qty) -> enterCounts -> post (creates the variance adjustment).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CountStatus,
CreateCountRequest,
EnterCountsRequest,
EnterCountsResponse,
PostCountResponse,
StockCount,
StockCountSummary,
} from "@/types/stock"
import {
allocateAdjLineId,
allocateAdjustmentId,
allocateCountId,
allocateCountLineId,
computeOnHand,
consumeFifo,
lastKnownCost,
mockDelay,
mockStockAdjustments,
mockStockCounts,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
export interface ListCountsParams {
page?: number
pageSize?: number
q?: string
status?: CountStatus
warehouseId?: number
}
function toSummary(c: (typeof mockStockCounts)[number]): StockCountSummary {
return {
countId: c.countId,
docNo: c.docNo,
warehouseId: c.warehouseId,
countType: c.countType,
status: c.status,
createdAt: c.createdAt,
}
sort?: string
}
export const stockCountsApi = {
list(params: ListCountsParams = {}): Promise<PagedResponse<StockCountSummary>> {
const filtered = mockStockCounts
.filter((c) => !params.warehouseId || c.warehouseId === params.warehouseId)
.map(toSummary)
.sort((a, b) => b.countId - a.countId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockCountSummary>>(`/stock-counts${buildQuery(params)}`)
},
get(countId: number): Promise<StockCount> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
return mockDelay(c)
return apiRequest<StockCount>(`/stock-counts/${countId}`)
},
create(request: CreateCountRequest): Promise<StockCount> {
const countId = allocateCountId()
const count = {
countId,
docNo: `CNT-2026-${String(countId).padStart(5, "0")}`,
warehouseId: request.warehouseId,
countType: request.countType,
status: "Draft" as CountStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.itemIds.map((itemId) => ({
countLineId: allocateCountLineId(),
itemId,
binId: null,
systemQty: computeOnHand(itemId, request.warehouseId).onHand,
countedQty: null,
variance: null,
})),
}
mockStockCounts.push(count)
return mockDelay(count)
return apiRequest<StockCount>("/stock-counts", { method: "POST", body: request })
},
enterCounts(countId: number, request: EnterCountsRequest): Promise<EnterCountsResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
for (const input of request.lines) {
const line = c.lines.find((l) => l.countLineId === input.countLineId)
if (!line) continue
line.countedQty = input.countedQty
line.variance = Math.round((input.countedQty - line.systemQty) * 100) / 100
}
return mockDelay({ lines: c.lines })
/** Returns the whole count (with server-computed variance), not just the lines. */
enterCounts(countId: number, request: EnterCountsRequest): Promise<StockCount> {
return apiRequest<StockCount>(`/stock-counts/${countId}/counts`, { method: "PUT", body: request })
},
/** Posts the variance adjustment and closes the count. `adjustmentId` is null if there was no variance. */
post(countId: number): Promise<PostCountResponse> {
const c = mockStockCounts.find((x) => x.countId === countId)
if (!c) return Promise.reject(new Error(`Mock count ${countId} not found`))
if (c.status === "Posted") return Promise.reject(new Error(`${c.docNo} has already been posted.`))
const adjustmentId = allocateAdjustmentId()
const ledgerRefs: number[] = []
const adjLines: { adjLineId: number; itemId: number; binId: number | null; batchId: number | null; qtyDelta: number }[] = []
for (const line of c.lines) {
if (!line.variance) continue
adjLines.push({ adjLineId: allocateAdjLineId(), itemId: line.itemId, binId: line.binId, batchId: null, qtyDelta: line.variance })
if (line.variance > 0) {
const unitCost = lastKnownCost(line.itemId, c.warehouseId)
const { ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: c.warehouseId,
qty: line.variance,
unitCost,
userId: 17,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
} else {
const chunks = consumeFifo(line.itemId, c.warehouseId, Math.abs(line.variance))
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: c.warehouseId,
userId: 17,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Count",
sourceDocId: c.countId,
})
ledgerRefs.push(ledger.ledgerId)
}
}
}
// Count Variance reason code (docs/8.3 seed list) — posted as its own adjustment record.
mockStockAdjustments.push({
adjustmentId,
docNo: `ADJ-2026-${String(adjustmentId).padStart(5, "0")}`,
warehouseId: c.warehouseId,
reasonCodeId: 3,
status: "Posted",
createdBy: 17,
createdAt: new Date().toISOString(),
lines: adjLines,
ledgerRefs,
})
c.status = "Posted"
return mockDelay({ countId: c.countId, status: c.status, adjustmentId, ledgerRefs })
return apiRequest<PostCountResponse>(`/stock-counts/${countId}/post`, { method: "POST" })
},
}
+14 -146
View File
@@ -1,5 +1,7 @@
// One typed client method per transfer endpoint (docs/11-BACKEND-PHASE1.md §5.4).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
// create -> dispatch -> receive. The server consumes source FIFO layers on dispatch and
// creates the destination layer at the inherited cost on receive (cost-preserving, FR-STK-06).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
CreateTransferRequest,
@@ -10,173 +12,39 @@ import {
StockTransferSummary,
TransferStatus,
} from "@/types/stock"
import {
MockTransferLine,
allocateTransferId,
allocateTransferLineId,
consumeFifo,
mockDelay,
mockStockTransfers,
postLedgerEntry,
receiveLayer,
} from "@/lib/api/mock-data"
export interface ListTransfersParams {
page?: number
pageSize?: number
q?: string
status?: TransferStatus
srcWarehouseId?: number
destWarehouseId?: number
}
function toPublicLine(line: MockTransferLine) {
return {
transferLineId: line.transferLineId,
itemId: line.itemId,
srcBinId: line.srcBinId,
destBinId: line.destBinId,
batchId: line.batchId,
qty: line.qty,
}
}
function toSummary(t: (typeof mockStockTransfers)[number]): StockTransferSummary {
return {
transferId: t.transferId,
docNo: t.docNo,
srcWarehouseId: t.srcWarehouseId,
destWarehouseId: t.destWarehouseId,
status: t.status,
createdAt: t.createdAt,
}
}
function toPublic(t: (typeof mockStockTransfers)[number]): StockTransfer {
return { ...toSummary(t), createdBy: t.createdBy, lines: t.lines.map(toPublicLine) }
sort?: string
}
export const stockTransfersApi = {
list(params: ListTransfersParams = {}): Promise<PagedResponse<StockTransferSummary>> {
const filtered = mockStockTransfers
.filter((t) => !params.status || t.status === params.status)
.filter((t) => !params.srcWarehouseId || t.srcWarehouseId === params.srcWarehouseId)
.filter((t) => !params.destWarehouseId || t.destWarehouseId === params.destWarehouseId)
.map(toSummary)
.sort((a, b) => b.transferId - a.transferId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 0 },
})
return apiRequest<PagedResponse<StockTransferSummary>>(`/stock-transfers${buildQuery(params)}`)
},
get(transferId: number): Promise<StockTransfer> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
return mockDelay(toPublic(t))
return apiRequest<StockTransfer>(`/stock-transfers/${transferId}`)
},
create(request: CreateTransferRequest): Promise<StockTransfer> {
const transferId = allocateTransferId()
const t = {
transferId,
docNo: `TRF-2026-${String(transferId).padStart(5, "0")}`,
srcWarehouseId: request.srcWarehouseId,
destWarehouseId: request.destWarehouseId,
status: "Draft" as TransferStatus,
createdBy: 17,
createdAt: new Date().toISOString(),
lines: request.lines.map((l) => ({
transferLineId: allocateTransferLineId(),
itemId: l.itemId,
srcBinId: l.srcBinId ?? null,
destBinId: l.destBinId ?? null,
batchId: l.batchId ?? null,
qty: l.qty,
dispatchedChunks: [],
})),
}
mockStockTransfers.push(t)
return mockDelay(toPublic(t))
return apiRequest<StockTransfer>("/stock-transfers", { method: "POST", body: request })
},
/** 409 STOCK_NEGATIVE_BLOCKED if source available < requested. */
dispatch(transferId: number): Promise<DispatchTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "Draft") return Promise.reject(new Error(`${t.docNo} has already been dispatched.`))
const consumedLayers: { layerId: number; qtyConsumed: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
try {
for (const line of t.lines) {
const chunks = consumeFifo(line.itemId, t.srcWarehouseId, line.qty)
line.dispatchedChunks = chunks
for (const chunk of chunks) {
const ledger = postLedgerEntry({
itemId: line.itemId,
warehouseId: t.srcWarehouseId,
binId: line.srcBinId,
batchId: line.batchId,
userId: t.createdBy,
direction: "Out",
qtyBase: chunk.qtyConsumed,
unitCost: chunk.unitCost,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
consumedLayers.push(chunk)
ledgerRefs.push(ledger.ledgerId)
}
}
} catch (err) {
return Promise.reject(err)
}
t.status = "InTransit"
return mockDelay({ transferId: t.transferId, status: t.status, consumedLayers, ledgerRefs })
return apiRequest<DispatchTransferResponse>(`/stock-transfers/${transferId}/dispatch`, { method: "POST" })
},
receive(transferId: number, lines: ReceiveTransferLineInput[]): Promise<ReceiveTransferResponse> {
const t = mockStockTransfers.find((x) => x.transferId === transferId)
if (!t) return Promise.reject(new Error(`Mock transfer ${transferId} not found`))
if (t.status !== "InTransit") return Promise.reject(new Error(`${t.docNo} is not in transit.`))
const createdLayers: { layerId: number; warehouseId: number; qtyReceived: number; unitCost: number }[] = []
const ledgerRefs: number[] = []
for (const input of lines) {
const line = t.lines.find((l) => l.transferLineId === input.transferLineId)
if (!line) continue
// Cost-preserving (FR-STK-06): one destination layer per dispatched chunk, at its exact source cost.
for (const chunk of line.dispatchedChunks) {
const { layer, ledger } = receiveLayer({
itemId: line.itemId,
warehouseId: t.destWarehouseId,
binId: line.destBinId,
batchId: line.batchId,
qty: chunk.qtyConsumed,
unitCost: chunk.unitCost,
userId: t.createdBy,
sourceDocType: "Transfer",
sourceDocId: t.transferId,
})
createdLayers.push({
layerId: layer.layerId,
warehouseId: layer.warehouseId,
qtyReceived: layer.qtyReceived,
unitCost: layer.unitCost,
})
ledgerRefs.push(ledger.ledgerId)
}
}
t.status = "Received"
return mockDelay({ transferId: t.transferId, status: t.status, createdLayers, ledgerRefs })
return apiRequest<ReceiveTransferResponse>(`/stock-transfers/${transferId}/receive`, {
method: "POST",
body: { lines },
})
},
}
+30 -117
View File
@@ -1,147 +1,60 @@
// One typed client method per stock-enquiry endpoint (docs/11-BACKEND-PHASE1.md §5.1-5.3, §5.7).
// In-memory Stock Core (lib/api/mock-data.ts) — no backend API calls.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { LedgerEntry, OnHand, ReorderAlert, ReorderRequisitionResponse, Valuation } from "@/types/stock"
import {
allocateReqLineId,
allocateRequisitionId,
computeOnHand,
knownStockKeys,
mockDelay,
mockItemReorders,
mockRequisitions,
mockStockLayers,
mockStockLedger,
} from "@/lib/api/mock-data"
export interface LedgerQuery {
itemId?: number
warehouseId?: number
/** `YYYY-MM-DD`. */
from?: string
to?: string
/**
* Document-type prefix as stored on the ledger: "GRN", "ADJ", "TRF", "PRET", "CNT"
* (Domain/DocumentTypes.cs) — not the friendly name. Pair with sourceDocId to ask
* "what movements did this document post?".
*/
sourceDocType?: string
sourceDocId?: number
page?: number
pageSize?: number
}
export interface OnHandListParams {
itemId?: number
warehouseId?: number
page?: number
pageSize?: number
}
export const stockApi = {
onHand(itemId: number, warehouseId: number): Promise<OnHand> {
const computed = computeOnHand(itemId, warehouseId)
return mockDelay({
itemId,
warehouseId,
...computed,
asOf: new Date().toISOString(),
})
return apiRequest<OnHand>(`/stock/on-hand${buildQuery({ itemId, warehouseId })}`)
},
/** Every item/warehouse pair currently on record — the Enquiry screen's row source. */
onHandList(): Promise<OnHand[]> {
const rows = knownStockKeys().map(({ itemId, warehouseId }) => ({
itemId,
warehouseId,
...computeOnHand(itemId, warehouseId),
asOf: new Date().toISOString(),
}))
return mockDelay(rows)
/** Every (item, warehouse) pair holding stock. Paged, unlike the old client-side version. */
onHandList(params: OnHandListParams = {}): Promise<PagedResponse<OnHand>> {
return apiRequest<PagedResponse<OnHand>>(`/stock/on-hand/list${buildQuery(params)}`)
},
ledger(params: LedgerQuery): Promise<PagedResponse<LedgerEntry>> {
const from = params.from ? new Date(params.from).getTime() : null
const to = params.to ? new Date(params.to).getTime() : null
const filtered = mockStockLedger
.filter((e) => !params.itemId || e.itemId === params.itemId)
.filter((e) => !params.warehouseId || e.warehouseId === params.warehouseId)
.filter((e) => from === null || new Date(e.createdAt).getTime() >= from)
.filter((e) => to === null || new Date(e.createdAt).getTime() <= to)
.sort((a, b) => b.ledgerId - a.ledgerId)
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
return mockDelay({
items,
pagination: {
page,
pageSize,
totalItems: filtered.length,
totalPages: pageSize <= 0 ? 0 : Math.ceil(filtered.length / pageSize),
},
})
return apiRequest<PagedResponse<LedgerEntry>>(`/stock/ledger${buildQuery(params)}`)
},
valuation(itemId: number, warehouseId: number): Promise<Valuation> {
const layers = mockStockLayers
.filter((l) => l.itemId === itemId && l.warehouseId === warehouseId && l.qtyRemaining > 0)
.sort((a, b) => new Date(a.receiptDate).getTime() - new Date(b.receiptDate).getTime())
.map((l) => ({
layerId: l.layerId,
qtyRemaining: l.qtyRemaining,
unitCost: l.unitCost,
value: Math.round(l.qtyRemaining * l.unitCost * 100) / 100,
receiptDate: l.receiptDate,
}))
const totalQty = layers.reduce((sum, l) => sum + l.qtyRemaining, 0)
const totalValue = Math.round(layers.reduce((sum, l) => sum + l.value, 0) * 100) / 100
return mockDelay({
itemId,
warehouseId,
layers,
totalQty,
totalValue,
currency: "LKR",
costingMethod: "FIFO",
})
return apiRequest<Valuation>(`/stock/valuation${buildQuery({ itemId, warehouseId })}`)
},
reorderAlerts(warehouseId?: number): Promise<PagedResponse<ReorderAlert>> {
const items = mockItemReorders
.filter((r) => !warehouseId || r.warehouseId === warehouseId)
.map((r) => ({ ...r, available: computeOnHand(r.itemId, r.warehouseId).available }))
.filter((r) => r.available <= r.reorderPoint)
.map((r) => ({
itemId: r.itemId,
warehouseId: r.warehouseId,
available: r.available,
reorderPoint: r.reorderPoint,
reorderQty: r.reorderQty,
suggestedRequisitionQty: r.reorderQty,
}))
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
/** Items at or below their reorder point — computed on read, no stored entity (FR-STK-10). */
reorderAlerts(warehouseId?: number, params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<ReorderAlert>> {
return apiRequest<PagedResponse<ReorderAlert>>(`/stock/reorder-alerts${buildQuery({ warehouseId, ...params })}`)
},
/** Creates a draft requisition for the suggested qty; returns the full requisition. */
createReorderRequisition(itemId: number, warehouseId: number): Promise<ReorderRequisitionResponse> {
const setting = mockItemReorders.find((r) => r.itemId === itemId && r.warehouseId === warehouseId)
const qty = setting?.reorderQty ?? 0
const requisitionId = allocateRequisitionId()
const requiredBy = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
// Genuinely lands in the Requisitions list (§3), not a fabricated response —
// same "wire mock modules together" posture as GRN confirm → Stock Core.
mockRequisitions.push({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
status: "Draft",
requestedBy: 17,
createdAt: new Date().toISOString(),
lines: [{ reqLineId: allocateReqLineId(), itemId, qty, requiredBy }],
})
return mockDelay({
requisitionId,
docNo: `PR-2026-${String(requisitionId).padStart(5, "0")}`,
itemId,
warehouseId,
qty,
status: "Draft",
})
return apiRequest<ReorderRequisitionResponse>(
`/stock/reorder-alerts/${itemId}/requisition${buildQuery({ warehouseId })}`,
{ method: "POST" },
)
},
}
+12 -16
View File
@@ -1,26 +1,22 @@
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
// `list` also backs the GRN/PO line UOM picker built in earlier sessions.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// `list` also backs the GRN/PO line UOM picker.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateUomRequest, Uom } from "@/types/master-data"
import { allocateUomId, mockDelay, mockUoms } from "@/lib/api/mock-data"
export interface ListUomsParams {
page?: number
pageSize?: number
q?: string
sort?: string
}
export const uomsApi = {
list(): Promise<PagedResponse<Uom>> {
return mockDelay({
items: mockUoms,
pagination: { page: 1, pageSize: 20, totalItems: mockUoms.length, totalPages: 1 },
})
list(params: ListUomsParams = {}): Promise<PagedResponse<Uom>> {
return apiRequest<PagedResponse<Uom>>(`/uoms${buildQuery(params)}`)
},
create(request: CreateUomRequest): Promise<Uom> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("UOM name is required."))
if (mockUoms.some((u) => u.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`UOM "${name}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const uom: Uom = { uomId: allocateUomId(), name }
mockUoms.push(uom)
return mockDelay(uom)
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
},
}
-49
View File
@@ -1,49 +0,0 @@
// One typed client method per Variant Category endpoint, mirroring lib/api/brands.ts.
// In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
import { PagedResponse } from "@/types/common"
import { CreateVariantCategoryRequest, UpdateVariantCategoryRequest, VariantCategory } from "@/types/master-data"
import { allocateVariantCategoryId, mockVariantCategories, mockDelay } from "@/lib/api/mock-data"
export const variantCategoriesApi = {
list(): Promise<PagedResponse<VariantCategory>> {
const items = [...mockVariantCategories].sort((a, b) => a.name.localeCompare(b.name))
return mockDelay({
items,
pagination: { page: 1, pageSize: items.length || 1, totalItems: items.length, totalPages: 1 },
})
},
create(request: CreateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
if (mockVariantCategories.some((c) => c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
const category: VariantCategory = {
variantCategoryId: allocateVariantCategoryId(),
name,
createdAt: new Date().toISOString(),
}
mockVariantCategories.push(category)
return mockDelay(category)
},
update(variantCategoryId: number, request: UpdateVariantCategoryRequest): Promise<VariantCategory> {
const name = request.name.trim()
if (!name) return Promise.reject(new Error("Category name is required."))
const category = mockVariantCategories.find((c) => c.variantCategoryId === variantCategoryId)
if (!category) return Promise.reject(new Error("Variant category not found."))
if (mockVariantCategories.some((c) => c.variantCategoryId !== variantCategoryId && c.name.toLowerCase() === name.toLowerCase())) {
return Promise.reject(new Error(`Variant category "${name}" already exists.`))
}
category.name = name
return mockDelay(category)
},
remove(variantCategoryId: number): Promise<void> {
const index = mockVariantCategories.findIndex((c) => c.variantCategoryId === variantCategoryId)
if (index === -1) return Promise.reject(new Error("Variant category not found."))
mockVariantCategories.splice(index, 1)
return mockDelay(undefined)
},
}
+10 -94
View File
@@ -1,120 +1,36 @@
// One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters
// are deactivated, not hard-deleted, FR-MD-08). In-memory sample data
// (lib/api/mock-data.ts) — no backend API calls.
// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are
// deactivated, not hard-deleted, FR-MD-08).
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import { Vendor } from "@/types/master-data"
import {
allocateVendorId,
bumpVendorVersion,
getVendorVersion,
initVendorVersion,
mockDelay,
mockVendors,
} from "@/lib/api/mock-data"
import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data"
export interface ListVendorsParams {
page?: number
pageSize?: number
q?: string
status?: EntityStatus
}
export interface CreateVendorRequest {
code: string
name: string
terms?: string | null
taxReg?: string | null
currency: string
}
export type UpdateVendorRequest = CreateVendorRequest
function codeTaken(code: string, excludeVendorId?: number) {
return mockVendors.some((v) => v.vendorId !== excludeVendorId && v.code.toLowerCase() === code.toLowerCase())
sort?: string
}
export const vendorsApi = {
list(params: ListVendorsParams = {}): Promise<PagedResponse<Vendor>> {
const term = params.q?.trim().toLowerCase()
const filtered = mockVendors
.filter((v) => !params.status || v.status === params.status)
.filter((v) => !term || `${v.code} ${v.name}`.toLowerCase().includes(term))
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const start = (page - 1) * pageSize
const items = filtered.slice(start, start + pageSize)
const totalItems = filtered.length
const totalPages = pageSize <= 0 ? 0 : Math.ceil(totalItems / pageSize)
return mockDelay({
items,
pagination: { page, pageSize, totalItems, totalPages },
})
return apiRequest<PagedResponse<Vendor>>(`/vendors${buildQuery(params)}`)
},
get(vendorId: number): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
return mockDelay({ data: v, etag: String(getVendorVersion(vendorId)) })
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`)
},
create(request: CreateVendorRequest): Promise<ApiResult<Vendor>> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const vendor: Vendor = {
vendorId: allocateVendorId(),
code,
name: request.name.trim(),
terms: request.terms?.trim() || null,
taxReg: request.taxReg?.trim() || null,
currency: request.currency.trim().toUpperCase(),
status: "Active",
createdAt: new Date().toISOString(),
updatedAt: null,
}
mockVendors.push(vendor)
initVendorVersion(vendor.vendorId)
return mockDelay({ data: vendor, etag: "1" })
return apiRequestWithETag<Vendor>("/vendors", { method: "POST", body: request })
},
update(vendorId: number, request: UpdateVendorRequest, ifMatch: string): Promise<ApiResult<Vendor>> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
if (String(getVendorVersion(vendorId)) !== ifMatch) {
return Promise.reject(
Object.assign(new Error("The vendor was modified by another request."), { code: "CONCURRENCY_CONFLICT" })
)
}
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Vendor code is required."))
if (codeTaken(code, vendorId)) {
return Promise.reject(Object.assign(new Error(`Vendor code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
v.code = code
v.name = request.name.trim()
v.terms = request.terms?.trim() || null
v.taxReg = request.taxReg?.trim() || null
v.currency = request.currency.trim().toUpperCase()
v.updatedAt = new Date().toISOString()
const next = bumpVendorVersion(vendorId)
return mockDelay({ data: v, etag: String(next) })
return apiRequestWithETag<Vendor>(`/vendors/${vendorId}`, { method: "PUT", body: request, ifMatch })
},
updateStatus(vendorId: number, status: EntityStatus): Promise<void> {
const v = mockVendors.find((x) => x.vendorId === vendorId)
if (!v) return Promise.reject(new Error(`Mock vendor ${vendorId} not found`))
v.status = status
v.updatedAt = new Date().toISOString()
bumpVendorVersion(vendorId)
return mockDelay(undefined)
return apiRequest<void>(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } })
},
}
+19 -40
View File
@@ -1,60 +1,39 @@
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
// FR-MD-07/FR-WH-01). In-memory sample data (lib/api/mock-data.ts) — no backend API calls.
// FR-MD-07/FR-WH-01).
//
// Warehouses are create-and-list only: there is no PUT, no status, and no ETag on them.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data"
import { allocateBinId, allocateWarehouseId, mockBins, mockDelay, mockWarehouses } from "@/lib/api/mock-data"
import { Bin, CreateBinRequest, CreateWarehouseRequest, Warehouse } from "@/types/master-data"
export interface CreateWarehouseRequest {
code: string
name: string
}
export type { CreateBinRequest, CreateWarehouseRequest }
export interface CreateBinRequest {
code: string
binType?: string | null
export interface ListWarehousesParams {
page?: number
pageSize?: number
q?: string
sort?: string
}
export const warehousesApi = {
list(): Promise<PagedResponse<Warehouse>> {
return mockDelay({
items: mockWarehouses,
pagination: { page: 1, pageSize: 20, totalItems: mockWarehouses.length, totalPages: 1 },
})
list(params: ListWarehousesParams = {}): Promise<PagedResponse<Warehouse>> {
return apiRequest<PagedResponse<Warehouse>>(`/warehouses${buildQuery(params)}`)
},
get(warehouseId: number): Promise<Warehouse> {
const wh = mockWarehouses.find((w) => w.warehouseId === warehouseId)
if (!wh) return Promise.reject(new Error(`Mock warehouse ${warehouseId} not found`))
return mockDelay(wh)
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`)
},
create(request: CreateWarehouseRequest): Promise<Warehouse> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Warehouse code is required."))
if (mockWarehouses.some((w) => w.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Warehouse code "${code}" already exists.`), { code: "SKU_DUPLICATE" }))
}
const warehouse: Warehouse = { warehouseId: allocateWarehouseId(), code, name: request.name.trim() }
mockWarehouses.push(warehouse)
return mockDelay(warehouse)
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
},
listBins(warehouseId: number): Promise<PagedResponse<Bin>> {
const items = mockBins.filter((b) => b.warehouseId === warehouseId)
return mockDelay({
items,
pagination: { page: 1, pageSize: 20, totalItems: items.length, totalPages: 1 },
})
/** Returns a bare array, not a paged envelope — the server does not page bins. */
listBins(warehouseId: number): Promise<Bin[]> {
return apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
},
createBin(warehouseId: number, request: CreateBinRequest): Promise<Bin> {
const code = request.code.trim()
if (!code) return Promise.reject(new Error("Bin code is required."))
if (mockBins.some((b) => b.warehouseId === warehouseId && b.code.toLowerCase() === code.toLowerCase())) {
return Promise.reject(Object.assign(new Error(`Bin code "${code}" already exists in this warehouse.`), { code: "SKU_DUPLICATE" }))
}
const bin: Bin = { binId: allocateBinId(), warehouseId, code, binType: request.binType?.trim() || null }
mockBins.push(bin)
return mockDelay(bin)
return apiRequest<Bin>(`/warehouses/${warehouseId}/bins`, { method: "POST", body: request })
},
}
+73 -49
View File
@@ -1,20 +1,29 @@
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the
// SRS — stock write-offs (damage, theft/loss, expiry) are modeled as Stock
// Adjustments with a mandatory reason code (FR-STK-07), and the reason-code seed
// list (docs/8.3 / docs/11 §6) already includes Damage / Theft-Loss / Expiry
// Write-off. This module is a frontend-only lens: it reuses stockAdjustmentsApi
// and the shared mock Stock Core, filtered to loss-type reason codes and
// flattened to per-line records for a focused "record wastage" flow and report.
// No new backend concept, no new mock store.
// "Wastage" is not a distinct document type in docs/10-BACKEND-PHASE1.md or the SRS —
// stock write-offs (damage, theft/loss, expiry) are modelled as Stock Adjustments with a
// mandatory reason code (FR-STK-07), and the seeded reason codes (docs/10 §B.8.3) already
// include Damage / Theft-Loss / Expiry Write-off. This module is a frontend-only lens over
// stockAdjustmentsApi: filtered to loss-type reasons and flattened to per-line records for
// a focused "record wastage" flow and report. No new backend concept.
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { mockDelay, mockReasonCodes, mockStockAdjustments, mockStockLedger } from "@/lib/api/mock-data"
import { stockApi } from "@/lib/api/stock"
import { StockAdjustment } from "@/types/stock"
/** Reason-code strings treated as "wastage" (loss-type) causes, per docs/8.3. */
const WASTAGE_CODES = new Set(["DMG", "LOSS", "EXPWO"])
/**
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
* Infra/Persistence/DataSeeder.cs — DMG/THEFT/EXP. (`VAR` count-variance and `SYS`
* corrections are adjustments too, but they are not losses, so they stay out.)
*/
const WASTAGE_CODES = new Set(["DMG", "THEFT", "EXP"])
export function wastageReasonCodeIds(): number[] {
return mockReasonCodes.filter((r) => WASTAGE_CODES.has(r.code)).map((r) => r.reasonCodeId)
/** Sync predicate for screens that already hold the reason-code list. */
export function isWastageReasonCode(code: string): boolean {
return WASTAGE_CODES.has(code)
}
export async function wastageReasonCodeIds(): Promise<number[]> {
const res = await reasonCodesApi.list("Adjustment", { pageSize: 200 })
return res.items.filter((r) => isWastageReasonCode(r.code)).map((r) => r.reasonCodeId)
}
export interface WastageRecord {
@@ -28,13 +37,15 @@ export interface WastageRecord {
reasonCodeId: number
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
qty: number
/** FIFO cost of the wasted quantity, summed from the matching outbound ledger entries. */
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
value: number
}
export interface ListWastageParams {
warehouseId?: number
reasonCodeId?: number
page?: number
pageSize?: number
}
export interface RecordWastageInput {
@@ -46,45 +57,58 @@ export interface RecordWastageInput {
}
export const wastageApi = {
list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(wastageReasonCodeIds())
const records: WastageRecord[] = []
/**
* Flattens loss-type adjustments to per-item records.
*
* Costs come from the ledger rather than being recomputed here: FIFO consumption is the
* server's job, and the browser cannot see the layers (docs/20 §3.3). One ledger call per
* adjustment is the price of the ledger's polymorphic source reference — acceptable
* because the adjustment page is already bounded.
*/
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(await wastageReasonCodeIds())
if (wastageIds.size === 0) return []
for (const adj of mockStockAdjustments) {
if (!wastageIds.has(adj.reasonCodeId)) continue
if (params.warehouseId && adj.warehouseId !== params.warehouseId) continue
if (params.reasonCodeId && adj.reasonCodeId !== params.reasonCodeId) continue
// The API filters by a single reasonCodeId, so a specific pick can be pushed down;
// otherwise fetch the page and keep the loss-type rows.
const res = await stockAdjustmentsApi.list({
warehouseId: params.warehouseId,
reasonCodeId: params.reasonCodeId,
page: params.page,
pageSize: params.pageSize ?? 50,
})
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
for (const line of adj.lines) {
if (line.qtyDelta >= 0) continue // wastage is always a decrease
const detailed = await Promise.all(
summaries.map(async (summary) => {
const [adjustment, ledger] = await Promise.all([
stockAdjustmentsApi.get(summary.adjustmentId),
stockApi.ledger({ sourceDocType: "ADJ", sourceDocId: summary.adjustmentId, pageSize: 200 }),
])
const value = mockStockLedger
.filter(
(l) =>
l.sourceDocType === "Adjustment" &&
l.sourceDocId === adj.adjustmentId &&
l.itemId === line.itemId &&
l.direction === "Out"
)
.reduce((sum, l) => sum + l.value, 0)
return adjustment.lines
.filter((line) => line.qtyDelta < 0) // wastage is always a decrease
.map<WastageRecord>((line) => {
const value = ledger.items
.filter((l) => l.itemId === line.itemId && l.direction === "Out")
.reduce((sum, l) => sum + l.value, 0)
return {
adjustmentId: adjustment.adjustmentId,
adjLineId: line.adjLineId,
docNo: adjustment.docNo,
createdAt: adjustment.createdAt,
warehouseId: adjustment.warehouseId,
itemId: line.itemId,
binId: line.binId,
reasonCodeId: adjustment.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
}
})
}),
)
records.push({
adjustmentId: adj.adjustmentId,
adjLineId: line.adjLineId,
docNo: adj.docNo,
createdAt: adj.createdAt,
warehouseId: adj.warehouseId,
itemId: line.itemId,
binId: line.binId,
reasonCodeId: adj.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
})
}
}
records.sort((a, b) => b.adjustmentId - a.adjustmentId)
return mockDelay(records)
return detailed.flat().sort((a, b) => b.adjustmentId - a.adjustmentId)
},
/** Records wastage as a single-line, negative-qtyDelta stock adjustment (FR-STK-07). */
+44
View File
@@ -0,0 +1,44 @@
// Client-side cache of the signed-in user's PROFILE, for display only.
//
// This is not an auth mechanism and holds no credentials: the session is the httpOnly
// `erp_at` cookie, which JS cannot read and which the API validates on every call. This
// exists only because there is no `GET /auth/me` endpoint — the user object arrives once,
// in the login/register response (docs/11 §2.0) — and the Header needs a name to show.
//
// Treat it as untrusted display data. Clearing it does not log anyone out; only the
// server clearing the cookie does that.
import { AuthUser } from "@/types/auth"
const KEY = "erpcore.user"
export function setStoredUser(user: AuthUser | null): void {
if (typeof window === "undefined") return
if (!user) {
window.localStorage.removeItem(KEY)
return
}
window.localStorage.setItem(KEY, JSON.stringify(user))
}
export function getStoredUser(): AuthUser | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthUser
} catch {
// Corrupt/legacy value — drop it rather than crash the shell.
window.localStorage.removeItem(KEY)
return null
}
}
export function clearStoredUser(): void {
setStoredUser(null)
}
/** Best display name available, falling back through the fields AuthHex may leave null. */
export function displayName(user: AuthUser | null): string {
if (!user) return "Signed in"
return user.fullname?.trim() || user.userName?.trim() || user.email?.trim() || "Signed in"
}
+11 -1
View File
@@ -8,6 +8,14 @@ interface ApiErrorLike {
errors?: Record<string, string[]>
}
/**
* Generic framework codes. Unlike the domain codes below, these say nothing on their own —
* the server's `detail` ("A brand named 'bosch' already exists.") is always more useful
* than "This action conflicts…", so for these the detail wins and the text here is only a
* last resort.
*/
const GENERIC_CODES = new Set(["validation_error", "not_found", "conflict"])
const CODE_MESSAGES: Record<string, string> = {
OVER_RECEIPT_TOLERANCE: "This quantity exceeds the purchase order's open quantity beyond the allowed tolerance.",
STOCK_NEGATIVE_BLOCKED: "Not enough available stock for this action.",
@@ -28,8 +36,10 @@ const CODE_MESSAGES: Record<string, string> = {
export function errorMessage(error: unknown): string {
if (error && typeof error === "object") {
const e = error as ApiErrorLike
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
// A specific domain code beats the server's prose; a generic one loses to it.
if (e.code && !GENERIC_CODES.has(e.code) && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
if (e.detail) return e.detail
if (e.code && CODE_MESSAGES[e.code]) return CODE_MESSAGES[e.code]
}
if (error instanceof Error) return error.message
return "Something went wrong."
+7 -10
View File
@@ -1,19 +1,16 @@
import { z } from "zod"
// Treats null/undefined as missing so validation surfaces one clear
// "required" message instead of a generic type error.
// Plain z.string(), not z.preprocess(): preprocess widens the schema's INPUT type to
// `unknown`, so zodResolver produced a Resolver<{email: unknown, …}> that could not be
// assigned to useForm<LoginValues> — the long-standing type error on the login page.
// Form fields always yield strings (RHF defaults them to ""), so the null/undefined
// coercion it was guarding against cannot occur here.
function requiredString(message: string) {
return z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, message)
)
return z.string().min(1, message)
}
// Email validation schema
export const emailSchema = z.preprocess(
(val) => (val === null || val === undefined ? "" : val),
z.string().min(1, "Email is required").email("Enter a valid email")
)
export const emailSchema = z.string().min(1, "Email is required").email("Enter a valid email")
// Login schema (email + password) for reuse across the app
export const loginSchema = z.object({
@@ -57,9 +57,10 @@ export function validateBrandName(name: string): Record<string, string> {
return errors
}
export function validateVariantCategoryName(name: string): Record<string, string> {
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
export function validateItemTypeName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Category name is required"
if (!name.trim()) errors.name = "Item type name is required"
return errors
}
+27
View File
@@ -0,0 +1,27 @@
import type { NextConfig } from "next"
import path from "path"
// The backend is proxied rather than called cross-origin. This makes /api/* same-origin,
// which means (a) no CORS config is needed on ERPCore, and (b) its Secure/SameSite=Strict
// session cookies just work. BACKEND_ORIGIN is server-side only — deliberately not
// NEXT_PUBLIC_*, since the browser only ever talks to this Next server.
//
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject.
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224"
const nextConfig: NextConfig = {
turbopack: {
root: path.join(__dirname),
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${BACKEND_ORIGIN}/api/:path*`,
},
]
},
}
export default nextConfig
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from "next/server"
// Route guard for the dashboard. (Next 16 renamed the `middleware` convention to `proxy`;
// this file is the same guard under the current name.)
//
// This is a PRESENCE CHECK ONLY, and deliberately so: `erp_at` is httpOnly and holds an
// RS256 JWT that only ERPCore can validate (docs/10 A.4), so the edge cannot tell whether
// it is real, expired, or forged. It exists purely so a logged-out user lands on /login
// instead of a dashboard full of 401s.
//
// The API remains the sole authority (docs/20-FRONTEND.md §3): every screen's data comes
// from calls that enforce auth server-side, and the client redirects to /login on a 401.
const ACCESS_COOKIE = "erp_at"
export default function proxy(request: NextRequest) {
const hasSession = request.cookies.has(ACCESS_COOKIE)
if (hasSession) return NextResponse.next()
const loginUrl = new URL("/login", request.url)
// Preserve where they were headed so login can send them back.
const { pathname, search } = request.nextUrl
if (pathname !== "/dashboard") loginUrl.searchParams.set("next", `${pathname}${search}`)
return NextResponse.redirect(loginUrl)
}
export const config = {
matcher: ["/dashboard/:path*"],
}
+46
View File
@@ -0,0 +1,46 @@
// Auth DTOs (docs/11-BACKEND-PHASE1.md §2.0 — ERPCore's proxy over the AuthHex IdP).
//
// Session-issuing responses carry NO tokens: the access/refresh tokens are delivered only
// as httpOnly cookies (erp_at / erp_rt). The body is user + expiry, nothing else.
/** AuthHex user projection. Note `fullname` is one word here (it is `fullName` on update). */
export interface AuthUser {
userId: string | null
roleId: string | null
userTypeId: string | null
fullname: string | null
userName: string | null
nic: string | null
email: string | null
mobileNumber: string | null
emailVerified: boolean | null
mobileNumberVerified: boolean | null
isActive: boolean | null
isLocked: boolean | null
}
export interface AuthSession {
user: AuthUser | null
/** Seconds until the access cookie expires. */
expiresIn: number
}
export interface LoginRequest {
identifier: string
password: string
/** Optional AuthHex user-type filter; the login screen does not send one. */
userTypeId?: string | null
deviceName?: string | null
}
export interface RegisterRequest {
roleId: string
userTypeId: string
fullname?: string | null
userName?: string | null
nic?: string | null
email?: string | null
mobileNumber?: string | null
password?: string | null
deviceName?: string | null
}
+18 -8
View File
@@ -1,13 +1,21 @@
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
//
// Deviation from the documented contract (recorded in Frontend/PROGRESS.md):
// the doc only specifies POST /grns, POST /grns/{id}/confirm and
// POST /grns/{id}/lines/{id}/release. A usable list/detail screen needs
// GET /grns and GET /grns/{id} — these are modelled here as a planned,
// not-yet-built backend extension (see Backend/PROGRESS.md when it lands).
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for
// Serial-tracked items, but the backend's CreateGrnLineInput has no such field (only
// `batch`). There is therefore deliberately no `serialNumbers` here: typing it would
// invite the UI to send data the server silently drops. Tracked in Frontend/PROGRESS.md.
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
export type HoldStatus = "OnHold" | "Available" | "Rejected"
export type PurchaseOrderStatus =
| "Draft"
| "PendingApproval"
| "Approved"
| "PartiallyReceived"
| "FullyReceived"
| "Closed"
| "Cancelled"
export interface BatchInput {
batchNo: string
@@ -24,8 +32,6 @@ export interface CreateGrnLineInput {
unitCost: number
holdStatus: HoldStatus
batch?: BatchInput | null
/** FR-GRN-04 — required when the item's trackingMode is "Serial"; length must equal qty. */
serialNumbers?: string[] | null
}
export interface CreateGrnRequest {
@@ -58,6 +64,7 @@ export interface Grn {
status: GrnStatus
createdBy: number
createdAt: string
postedAt: string | null
lines: GrnLine[]
}
@@ -68,7 +75,10 @@ export interface GrnSummary {
vendorId: number
warehouseId: number
status: GrnStatus
createdBy: number
createdAt: string
postedAt: string | null
lineCount: number
}
/** docs/11 §4.2 confirm response. */
@@ -89,7 +99,7 @@ export interface ConfirmGrnResponse {
postedAt: string
createdLayers: CreatedLayer[]
ledgerRefs: number[]
poStatus: string | null
poStatus: PurchaseOrderStatus | null
}
export type ReleaseAction = "Release" | "Reject"
+107 -28
View File
@@ -1,7 +1,13 @@
// Master Data DTOs (docs/11-BACKEND-PHASE1.md §2; FR-MD-01..08).
// Master Data DTOs (docs/11-BACKEND-PHASE1.md §2; FR-MD-01..11).
// Mirrors the backend DTOs exactly — the API contract is the source of truth (docs/20 §1).
import { EntityStatus } from "@/types/common"
export type ItemType = "Stocked" | "NonStocked" | "Service"
/**
* Whether an item holds stock. Renamed from `ItemType` on 2026-07-16 (docs/11 §8): the
* name `ItemType` now means a *dimension master* (Color/Size), which is a different
* concept entirely — see {@link ItemType} below.
*/
export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export interface ItemListItem {
@@ -9,10 +15,11 @@ export interface ItemListItem {
sku: string
name: string
categoryId: number
brandId?: number | null
subCategoryId: number | null
brandId: number | null
baseUomId: number
defaultVendorId: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass: string | null
status: EntityStatus
@@ -32,14 +39,11 @@ export interface UomConversion {
}
/**
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`). `conversions` is a
* deviation: the documented example response only shows `reorder` inline, and
* conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions`
* (which returns them but has no matching GET). Embedding them here — like the
* assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app — lets the Item
* detail screen show current conversions before editing; flag this to whoever
* implements the real endpoint (docs/10 §4 already lists UOMConversion as its own
* entity, so the full resource plausibly carries it).
* Full Item resource (docs/11 §2.1 `GET /items/{itemId}`).
*
* Note there is no item-type field: the values chosen in the builder are encoded into the
* client-generated `sku` and never stored server-side (docs/10 Part C.9). The SKU is the
* only record of which colour/size an item is.
*/
export interface Item {
itemId: number
@@ -47,33 +51,36 @@ export interface Item {
name: string
description: string | null
categoryId: number
brandId?: number | null
/** Optional second level; must belong to `categoryId` or the server returns 422. */
subCategoryId: number | null
brandId: number | null
baseUomId: number
defaultVendorId: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass: string | null
status: EntityStatus
reorder: ItemReorderSetting[]
conversions: UomConversion[]
/** Quantity captured at creation time (e.g. from the variant builder). Not wired into the Stock Core ledger — informational only. */
initialQty?: number | null
createdAt: string
updatedAt: string | null
}
export interface CreateItemRequest {
/** Client-generated; the server only enforces uniqueness (`SKU_DUPLICATE`). */
sku: string
name: string
description?: string | null
categoryId: number
/** Rejected with `CONFIG_DISABLED` when subcategories are switched off (docs/11 §2.8). */
subCategoryId?: number | null
/** Rejected with `CONFIG_DISABLED` when brands are switched off. */
brandId?: number | null
baseUomId: number
defaultVendorId?: number | null
itemType: ItemType
stockNature: StockNature
trackingMode: TrackingMode
taxClass?: string | null
initialQty?: number | null
}
export type UpdateItemRequest = CreateItemRequest
@@ -98,6 +105,11 @@ export interface Warehouse {
name: string
}
export interface CreateWarehouseRequest {
code: string
name: string
}
export interface Bin {
binId: number
warehouseId: number
@@ -105,6 +117,11 @@ export interface Bin {
binType: string | null
}
export interface CreateBinRequest {
code: string
binType?: string | null
}
export interface Vendor {
vendorId: number
code: string
@@ -117,6 +134,16 @@ export interface Vendor {
updatedAt: string | null
}
export interface CreateVendorRequest {
code: string
name: string
terms?: string | null
taxReg?: string | null
currency: string
}
export type UpdateVendorRequest = CreateVendorRequest
export interface Uom {
uomId: number
name: string
@@ -126,30 +153,49 @@ export interface CreateUomRequest {
name: string
}
// Category / SubCategory — a fixed two-level hierarchy. Categories no longer self-nest:
// `parentId` and `GET /categories?tree=true` were removed on 2026-07-16 (docs/11 §2.3).
export interface Category {
categoryId: number
name: string
parentId: number | null
status: EntityStatus
createdAt: string
}
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[]
updatedAt: string | null
}
export interface CreateCategoryRequest {
name: string
parentId?: number | null
}
export interface UpdateCategoryRequest {
name: string
}
export interface SubCategory {
subCategoryId: number
categoryId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateSubCategoryRequest {
name: string
}
/** Name only — a subcategory cannot be reparented (docs/11 §2.3). */
export interface UpdateSubCategoryRequest {
name: string
}
export interface Brand {
brandId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateBrandRequest {
@@ -160,16 +206,49 @@ export interface UpdateBrandRequest {
name: string
}
export interface VariantCategory {
variantCategoryId: number
/**
* Item type master (docs/11 §2.7) — a dimension *name* such as Color, Size or Material.
* Formerly `VariantCategory` in this app.
*
* Nothing links an item to one of these: it exists only to populate the builder's
* dropdown. The chosen values are baked into the SKU client-side. Not to be confused with
* {@link StockNature}, which is what the old `itemType` enum became.
*/
export interface ItemType {
itemTypeId: number
name: string
status: EntityStatus
createdAt: string
updatedAt: string | null
}
export interface CreateVariantCategoryRequest {
export interface CreateItemTypeRequest {
name: string
}
export interface UpdateVariantCategoryRequest {
export interface UpdateItemTypeRequest {
name: string
}
/**
* Product configuration (docs/11 §2.8) — singleton feature gate.
*
* `subcategoriesEnabled`/`brandsEnabled` are enforced server-side (`CONFIG_DISABLED` on
* item writes). `itemTypesEnabled` is **advisory**: items carry no item-type reference, so
* there is nothing for the server to reject — the frontend is what honours it by hiding
* the builder's type section.
*/
export interface ProductConfig {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
updatedAt: string | null
updatedBy: number | null
}
/** All three flags are required: an omitted flag is a 400, never a silent disable. */
export interface UpdateProductConfigRequest {
subcategoriesEnabled: boolean
brandsEnabled: boolean
itemTypesEnabled: boolean
}
+29 -22
View File
@@ -1,6 +1,5 @@
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09). Mirrors the
// planned Dtos/Procurement/*.cs — no Procurement backend exists yet, see
// Frontend/PROGRESS.md §3 for the same frontend-only posture as GRN/Stock.
// Procurement DTOs (docs/11-BACKEND-PHASE1.md §3; FR-PROC-01..09).
// Mirrors the live Dtos/Procurement/*.cs — the API contract is the source of truth.
// --- 3.1 Requisitions --------------------------------------------------------------
@@ -10,7 +9,8 @@ export interface ReqLine {
reqLineId: number
itemId: number
qty: number
requiredBy: string
/** Date only, `YYYY-MM-DD`. */
requiredBy: string | null
}
export interface Requisition {
@@ -34,7 +34,7 @@ export interface RequisitionSummary {
export interface CreateReqLineInput {
itemId: number
qty: number
requiredBy: string
requiredBy?: string | null
}
export interface CreateRequisitionRequest {
@@ -43,8 +43,6 @@ export interface CreateRequisitionRequest {
// --- 3.2 RFQs & Quotations ----------------------------------------------------------
/** Phase 1 only documents "Open" on creation; "Closed" is a frontend-only convenience
* applied once a PO is created from the RFQ (see Frontend/PROGRESS.md §3 deviation note). */
export type RfqStatus = "Open" | "Closed"
export interface RfqLine {
@@ -53,23 +51,26 @@ export interface RfqLine {
qty: number
}
/**
* Note: the server returns no `vendorIds` or `createdAt` on an RFQ. The invited-vendor
* list is validated on create but not persisted (no RFQ↔vendor link in the model);
* quotations reference vendors directly.
*/
export interface Rfq {
rfqId: number
docNo: string
requisitionId: number | null
requisitionId: number
status: RfqStatus
vendorIds: number[]
createdAt: string
lines: RfqLine[]
}
export interface RfqSummary {
rfqId: number
docNo: string
requisitionId: number | null
requisitionId: number
status: RfqStatus
vendorIds: number[]
createdAt: string
lineCount: number
quotationCount: number
}
export interface CreateRfqLineInput {
@@ -78,7 +79,8 @@ export interface CreateRfqLineInput {
}
export interface CreateRfqRequest {
requisitionId?: number | null
/** Required — an RFQ is always raised against a requisition. */
requisitionId: number
vendorIds: number[]
lines: CreateRfqLineInput[]
}
@@ -93,7 +95,6 @@ export interface Quotation {
quotationId: number
rfqId: number
vendorId: number
createdAt: string
lines: QuotationLine[]
}
@@ -104,20 +105,21 @@ export interface CreateQuotationRequest {
export interface RfqComparisonCell {
vendorId: number
quotationId: number
unitPrice: number
leadDays: number
}
export interface RfqComparisonLine {
export interface RfqComparisonRow {
itemId: number
qty: number
cells: RfqComparisonCell[]
quotes: RfqComparisonCell[]
}
export interface RfqComparison {
rfqId: number
vendorIds: number[]
lines: RfqComparisonLine[]
rows: RfqComparisonRow[]
}
// --- 3.3 Purchase Orders -------------------------------------------------------------
@@ -138,6 +140,7 @@ export interface PoLine {
warehouseId: number
qty: number
unitPrice: number
/** Rate, 0..1 (e.g. 0.18), not an amount. */
tax: number
qtyReceived: number
}
@@ -192,16 +195,17 @@ export interface CreatePurchaseOrderRequest {
export type UpdatePurchaseOrderRequest = CreatePurchaseOrderRequest
export interface CancelPurchaseOrderRequest {
reason: string
reason?: string | null
}
// --- 3.4 Purchase Returns -------------------------------------------------------------
export type PurchaseReturnStatus = "Posted"
export type PurchaseReturnStatus = "Draft" | "Posted"
export interface PurchaseReturnLine {
returnLineId: number
grnLineId: number
/** Optional: a return may reference the originating GRN line for traceability. */
grnLineId: number | null
itemId: number
qty: number
}
@@ -226,11 +230,13 @@ export interface PurchaseReturnSummary {
warehouseId: number
reasonCodeId: number
status: PurchaseReturnStatus
createdBy: number
createdAt: string
lineCount: number
}
export interface CreatePurchaseReturnLineInput {
grnLineId: number
grnLineId?: number | null
itemId: number
qty: number
}
@@ -238,6 +244,7 @@ export interface CreatePurchaseReturnLineInput {
export interface CreatePurchaseReturnRequest {
vendorId: number
warehouseId: number
/** Mandatory (FR-PROC-08); omitting it returns 400 REASON_CODE_REQUIRED. */
reasonCodeId: number
lines: CreatePurchaseReturnLineInput[]
}
+30 -23
View File
@@ -1,11 +1,7 @@
// Stock Management types (docs/11-BACKEND-PHASE1.md §5; FR-STK-01..14, FR-WH-*).
//
// Deviation from the documented contract (recorded in Frontend/PROGRESS.md,
// same pattern as the GRN deviation): §5.4/5.5/5.6 document only the
// transactional POSTs/PUT for transfers/adjustments/counts, not a list/detail
// GET. A usable list+detail UI needs to reload these documents, so this file
// (and the matching lib/api/stock-*.ts modules) assume GET list/detail
// endpoints will exist once the real Stock Core backend is built.
// Mirrors the live backend DTOs. The list endpoints (GET /stock-transfers,
// /stock-adjustments, /stock-counts, /stock/on-hand/list) all exist as of 2026-07-16.
import { Requisition } from "@/types/procurement"
export type Currency = "LKR"
@@ -17,7 +13,10 @@ export interface OnHand {
onHand: number
available: number
onHold: number
/** Reported for visibility only — dispatch already removed it from onHand, so it is
* NOT subtracted again from available (docs/10 C.9). */
inTransit: number
/** Always 0 in Phase 1 (sales reservation stub, FR-STK-11). */
reserved: number
asOf: string
}
@@ -75,6 +74,8 @@ export interface TransferLine {
destBinId: number | null
batchId: number | null
qty: number
/** Accrues on partial receive; qty - qtyReceived is what is still in transit. */
qtyReceived: number
}
export interface StockTransfer {
@@ -94,7 +95,9 @@ export interface StockTransferSummary {
srcWarehouseId: number
destWarehouseId: number
status: TransferStatus
createdBy: number
createdAt: string
lineCount: number
}
export interface CreateTransferLineInput {
@@ -129,6 +132,10 @@ export interface ReceiveTransferLineInput {
qty: number
}
export interface ReceiveTransferRequest {
lines: ReceiveTransferLineInput[]
}
export interface CreatedTransferLayer {
layerId: number
warehouseId: number
@@ -145,13 +152,16 @@ export interface ReceiveTransferResponse {
// --- 5.5 Adjustments ------------------------------------------------------------------
export type AdjustmentStatus = "Posted"
/** Phase 1 auto-posts, so `Posted` is what you see; the other two exist for the deferred
* approval workflow (FR-STK-07) and are in the server enum. */
export type AdjustmentStatus = "Draft" | "PendingApproval" | "Posted"
export interface AdjustmentLine {
adjLineId: number
itemId: number
binId: number | null
batchId: number | null
/** Signed: negative FIFO-consumes, positive creates a layer. */
qtyDelta: number
}
@@ -173,7 +183,9 @@ export interface StockAdjustmentSummary {
warehouseId: number
reasonCodeId: number
status: AdjustmentStatus
createdBy: number
createdAt: string
lineCount: number
}
export interface CreateAdjustmentLineInput {
@@ -185,6 +197,7 @@ export interface CreateAdjustmentLineInput {
export interface CreateAdjustmentRequest {
warehouseId: number
/** Mandatory (FR-STK-07); omitting it returns 400 REASON_CODE_REQUIRED. */
reasonCodeId: number
lines: CreateAdjustmentLineInput[]
}
@@ -192,7 +205,7 @@ export interface CreateAdjustmentRequest {
// --- 5.6 Counts ------------------------------------------------------------------
export type CountType = "Cycle" | "Full"
export type CountStatus = "Draft" | "Posted"
export type CountStatus = "Draft" | "Counted" | "Posted"
export interface CountLine {
countLineId: number
@@ -220,7 +233,9 @@ export interface StockCountSummary {
warehouseId: number
countType: CountType
status: CountStatus
createdBy: number
createdAt: string
lineCount: number
}
export interface CreateCountRequest {
@@ -238,14 +253,11 @@ export interface EnterCountsRequest {
lines: EnterCountLineInput[]
}
export interface EnterCountsResponse {
lines: CountLine[]
}
export interface PostCountResponse {
countId: number
status: CountStatus
adjustmentId: number
/** Null when the count had no variance to post. */
adjustmentId: number | null
ledgerRefs: number[]
}
@@ -260,18 +272,13 @@ export interface ReorderAlert {
suggestedRequisitionQty: number
}
export interface ReorderRequisitionResponse {
requisitionId: number
docNo: string
itemId: number
warehouseId: number
qty: number
status: "Draft"
}
/** POST /stock/reorder-alerts/{itemId}/requisition returns a full Requisition. */
export type ReorderRequisitionResponse = Requisition
// --- §6 Reference data ------------------------------------------------------------------
export type ReasonCodeContext = "Adjustment" | "Return" | "CountVariance"
/** Server enum is Adjustment | Return | Count (docs/11 §6). */
export type ReasonCodeContext = "Adjustment" | "Return" | "Count"
export interface ReasonCode {
reasonCodeId: number