feat: add warehouse and bin management API with mock data implementation

- Implemented warehouses API with methods for listing, creating, and managing bins.
- Added wastage API to handle stock write-offs and integrate with stock adjustments.
- Created auth token management for storing and retrieving access tokens.
- Developed error mapping for consistent user-facing error messages.
- Introduced client-side validations for GRN, master data, and procurement processes.
- Defined common types for pagination, problem details, and various master data entities.
- Established procurement and stock management types to support frontend functionality.
This commit is contained in:
2026-07-13 17:11:40 +05:30
parent 4e84a15db7
commit 7ac30bb454
79 changed files with 14042 additions and 44 deletions
@@ -1,5 +1,6 @@
import { AppSidebar } from "@/components/Layouts/AppSidebar"
import { Header } from "@/components/Layouts/Header"
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
import { Toaster } from "@/components/ui/toast"
export default function DashboardLayout({
@@ -14,6 +15,7 @@ export default function DashboardLayout({
<Header />
<div className="flex-1 overflow-auto">
<div className="p-6 lg:p-8">
<Breadcrumbs />
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
<div className="p-6">
{children}
@@ -0,0 +1,64 @@
import Link from "next/link"
import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Requisitions",
description: "Raise a purchase requisition and submit it into procurement.",
href: "/dashboard/procurement/requisitions",
icon: ClipboardList,
},
{
title: "RFQs",
description: "Request quotations from vendors, record pricing, and compare side by side.",
href: "/dashboard/procurement/rfqs",
icon: FileText,
},
{
title: "Purchase Orders",
description: "Auto-approved on creation, freely editable while open, cancellable before receipt.",
href: "/dashboard/procurement/purchase-orders",
icon: ShoppingCart,
},
{
title: "Purchase Returns",
description: "Return received goods to a vendor, referencing the original GRN line.",
href: "/dashboard/procurement/purchase-returns",
icon: PackageX,
},
]
export default function ProcurementHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Procurement</h1>
<p className="text-base text-muted-foreground">
Requisition RFQ (optional) Purchase Order Purchase Return (FR-PROC-01..09).
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,459 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { ApiError } from "@/lib/api-client"
import { errorMessage } from "@/lib/error-map"
import { validatePoLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
import { PoStatusBadge } from "@/components/procurement/status-badges"
interface DraftLine {
key: string
poLineId: number | null
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
tax: string
qtyReceived: number
}
let keySeq = 0
function newKey() {
keySeq += 1
return `poeditline-${keySeq}`
}
export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const poId = Number(params.id)
const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [vendors, setVendors] = useState<Vendor[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [lines, setLines] = useState<DraftLine[]>([])
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [showCancelForm, setShowCancelForm] = useState(false)
const [cancelReason, setCancelReason] = useState("")
const [cancelling, setCancelling] = useState(false)
function toDraftLines(order: PurchaseOrder): DraftLine[] {
return order.lines.map((l) => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
warehouseId: l.warehouseId,
qty: String(l.qty),
unitPrice: String(l.unitPrice),
tax: String(l.tax),
qtyReceived: l.qtyReceived,
}))
}
function load() {
setLoadError(null)
purchaseOrdersApi
.getWithETag(poId)
.then(({ data, etag: tag }) => {
setPo(data)
setEtag(tag)
setLines(toDraftLines(data))
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
}
useEffect(() => {
if (!Number.isFinite(poId)) return
load()
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list(), warehousesApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([it, uo, wh, ve]) => {
setItems(it.items)
setUoms(uo.items)
setWarehouses(wh.items)
setVendors(ve.items)
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poId])
function itemFor(itemId: number | null) {
return items.find((i) => i.itemId === itemId) ?? null
}
function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
function warehouseCode(warehouseId: number) {
return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}`
}
function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
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))
}
async function handleSave() {
if (!po || !etag) return
setSaveError(null)
if (lines.length === 0) {
setSaveError("A purchase order needs at least one line.")
return
}
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const errors = validatePoLine({
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
tax: line.tax,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSaveError("Fix the highlighted lines before saving.")
return
}
const payloadLines: CreatePoLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
uomId: l.uomId as number,
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
tax: Number(l.tax),
}))
setSaving(true)
try {
const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag)
setPo(result.data)
setEtag(result.etag)
setLines(toDraftLines(result.data))
toast.success("Purchase order saved", `${result.data.docNo} updated (FR-PROC-05, edit-while-open).`)
} catch (err) {
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
return
}
setSaveError(errorMessage(err))
toast.error("Could not save purchase order", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleCancel() {
if (!po) return
if (!cancelReason.trim()) {
setSaveError("A cancellation reason is required.")
return
}
setCancelling(true)
try {
const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() })
setPo(updated)
setShowCancelForm(false)
toast.success("Purchase order cancelled", updated.docNo)
} catch (err) {
toast.error("Could not cancel purchase order", errorMessage(err))
} finally {
setCancelling(false)
}
}
if (loadError && !po) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline" }))}>
Back to purchase orders
</Link>
</div>
)
}
if (!po) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
const editable = isPoEditable(po.status) && !conflict
const hasReceipts = po.lines.some((l) => l.qtyReceived > 0)
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/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
<PoStatusBadge status={po.status} />
</div>
<p className="text-base text-muted-foreground">
Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} {po.totals.currency} {po.totals.grandTotal.toFixed(2)}
</p>
</div>
</div>
{isPoEditable(po.status) && !showCancelForm && (
<Button
variant="destructive"
size="lg"
onClick={() => setShowCancelForm(true)}
disabled={hasReceipts}
title={hasReceipts ? "Cannot cancel — this PO already has receipts against it" : undefined}
>
<Ban className="size-5" />
Cancel PO
</Button>
)}
</div>
{showCancelForm && (
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-5">
<p className="text-base font-semibold text-destructive">Cancel {po.docNo}</p>
<Input
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Reason (e.g. Duplicate order)"
className="h-11 max-w-md text-base"
/>
<div className="flex gap-3">
<Button variant="outline" onClick={() => setShowCancelForm(false)} disabled={cancelling}>
Back
</Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Confirm cancellation"}
</Button>
</div>
</div>
)}
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This purchase order was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</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>
{editable && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, { key: newKey(), poLineId: null, itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18", qtyReceived: 0 }])}>
<Plus className="size-5" />
Add line
</Button>
)}
</div>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Tax</TableHead>
{editable && <TableHead className="h-12 w-10 px-3" />}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
if (!editable) {
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.uomId ? uomName(line.uomId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseId ? warehouseCode(line.warehouseId) : "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.qtyReceived}</TableCell>
<TableCell className="px-3 py-3.5">{Number(line.unitPrice).toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{(Number(line.tax) * 100).toFixed(0)}%</TableCell>
</TableRow>
)
}
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.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min={line.qtyReceived}
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.5">{line.qtyReceived}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={line.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="0.01"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
aria-label="Remove line"
disabled={line.qtyReceived > 0}
title={line.qtyReceived > 0 ? "Cannot remove a line that already has receipts" : undefined}
>
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
{editable && (
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/procurement/purchase-orders")}>
Back
</Button>
<Button size="lg" onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,411 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { requisitionsApi } from "@/lib/api/requisitions"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
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 { validatePoLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreatePoLineInput } from "@/types/procurement"
import { ItemListItem, Uom, Vendor, 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
itemId: number | null
uomId: number | null
warehouseId: number | null
qty: string
unitPrice: string
tax: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `poline-${keySeq}`
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" }
}
function NewPurchaseOrderContent() {
const router = useRouter()
const searchParams = useSearchParams()
const requisitionId = Number(searchParams.get("requisitionId")) || null
const rfqId = Number(searchParams.get("rfqId")) || null
const rfqVendorId = Number(searchParams.get("vendorId")) || null
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId)
const [loadError, setLoadError] = useState<string | null>(null)
const [vendorId, setVendorId] = useState<number | null>(rfqVendorId)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
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(() => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
warehousesApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }),
])
.then(([it, uo, wh, ve]) => {
setItems(it.items)
setUoms(uo.items)
setWarehouses(wh.items)
setVendors(ve.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (requisitionId) {
requisitionsApi
.get(requisitionId)
.then((req) => {
setLines(
req.lines.map(
(l): DraftLine => ({
key: newKey(),
itemId: l.itemId,
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: "",
tax: "0.18",
})
)
)
})
.catch((err) => setHeaderError(errorMessage(err)))
.finally(() => setPrefillLoading(false))
return
}
if (rfqId && rfqVendorId) {
Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)])
.then(([rfq, comparison]) => {
setVendorId(rfqVendorId)
setLines(
rfq.lines.map((l): DraftLine => {
const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId)
return {
key: newKey(),
itemId: l.itemId,
uomId: null,
warehouseId: null,
qty: String(l.qty),
unitPrice: cell ? String(cell.unitPrice) : "",
tax: "0.18",
}
})
)
})
.catch((err) => setHeaderError(errorMessage(err)))
.finally(() => setPrefillLoading(false))
return
}
setPrefillLoading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId, rfqId, rfqVendorId])
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() {
setHeaderError(null)
setSubmitError(null)
if (!vendorId) {
setHeaderError("Select a vendor.")
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 = validatePoLine({
itemId: line.itemId,
uomId: line.uomId,
warehouseId: line.warehouseId,
qty: line.qty,
unitPrice: line.unitPrice,
tax: line.tax,
})
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: CreatePoLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
uomId: l.uomId as number,
warehouseId: l.warehouseId as number,
qty: Number(l.qty),
unitPrice: Number(l.unitPrice),
tax: Number(l.tax),
}))
setSubmitting(true)
try {
const { data: po } = await purchaseOrdersApi.create({
vendorId,
requisitionId: requisitionId ?? (rfqId ? undefined : null),
lines: payloadLines,
})
toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`)
router.push(`/dashboard/procurement/purchase-orders/${po.poId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !items || !uoms || !warehouses || !vendors || prefillLoading
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 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">Vendor</Label>
<Select<number | null> value={vendorId} onValueChange={setVendorId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{requisitionId && (
<div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From Requisition #{requisitionId}</div>
)}
{rfqId && <div className="flex flex-col justify-end pb-2.5 text-sm text-muted-foreground">From RFQ #{rfqId}</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 && (
<div className="overflow-x-auto">
<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-40 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Tax</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId || rfqId ? (
<div className="flex h-11 items-center text-base">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<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.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</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.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="0.01"
value={line.tax}
aria-invalid={!!errors.tax}
onChange={(e) => updateLine(line.key, { tax: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.tax ? { message: errors.tax } : undefined]} />
</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>
)}
</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/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create PO"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewPurchaseOrderPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewPurchaseOrderContent />
</Suspense>
)
}
@@ -0,0 +1,186 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 10
export default function PurchaseOrdersListPage() {
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
function load() {
setError(null)
purchaseOrdersApi
.list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status })
.then((res) => {
setPos(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, query, status])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
const hasFilters = query.length > 0 || status !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<p className="text-base text-muted-foreground">Auto-approved on creation and freely editable while open (FR-PROC-03..05).</p>
</div>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New PO
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., vendor…"
className="h-12 w-full flex-1 basis-0 text-base"
aria-label="Search purchase orders"
/>
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-12! w-full sm:w-56 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Approved" className="text-base">Approved</SelectItem>
<SelectItem value="PartiallyReceived" className="text-base">Partially received</SelectItem>
<SelectItem value="FullyReceived" className="text-base">Fully received</SelectItem>
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button type="button" variant="ghost" onClick={() => { setSearchInput(""); setStatus("All") }}>
Clear filters
</Button>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && pos === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && pos !== null && pos.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ShoppingCart className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}</p>
</div>
)}
{!error && pos !== null && pos.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => (
<TableRow key={po.poId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(po.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,286 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
import { grnsApi } from "@/lib/api/grns"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateReturnLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreatePurchaseReturnLineInput } from "@/types/procurement"
import { Grn, GrnLine } from "@/types/grn"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
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"
import { HoldStatusBadge } from "@/components/receiving/status-badges"
interface LineState {
selected: boolean
qty: string
}
function NewPurchaseReturnContent() {
const router = useRouter()
const searchParams = useSearchParams()
const presetGrnId = Number(searchParams.get("grnId")) || null
const presetGrnLineId = Number(searchParams.get("grnLineId")) || null
const [grns, setGrns] = useState<Grn[] | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [grnId, setGrnId] = useState<number | null>(presetGrnId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lineState, setLineState] = useState<Record<number, LineState>>({})
const [lineErrors, setLineErrors] = useState<Record<number, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
.then(([grnList, it, rc]) => {
// Only Confirmed/Closed GRNs have posted stock layers to return against.
Promise.all(grnList.items.filter((g) => g.status === "Confirmed" || g.status === "Closed").map((g) => grnsApi.get(g.grnId))).then(setGrns)
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null
useEffect(() => {
if (!selectedGrn) {
setLineState({})
return
}
const next: Record<number, LineState> = {}
for (const line of selectedGrn.lines) {
next[line.grnLineId] = {
selected: presetGrnLineId ? line.grnLineId === presetGrnLineId : false,
qty: presetGrnLineId && line.grnLineId === presetGrnLineId ? String(line.qty) : "",
}
}
setLineState(next)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedGrn?.grnId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function toggleLine(line: GrnLine) {
setLineState((prev) => ({
...prev,
[line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) },
}))
}
function setQty(grnLineId: number, qty: string) {
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } }))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (!selectedGrn) {
setHeaderError("Select a GRN to return against.")
return
}
if (!reasonCodeId) {
setHeaderError("Select a reason code.")
return
}
const selectedLines = selectedGrn.lines.filter((l) => lineState[l.grnLineId]?.selected)
if (selectedLines.length === 0) {
setSubmitError("Select at least one line to return.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) {
const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty })
if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors
}
setLineErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreatePurchaseReturnLineInput[] = selectedLines.map((l) => ({
grnLineId: l.grnLineId,
itemId: l.itemId,
qty: Number(lineState[l.grnLineId].qty),
}))
setSubmitting(true)
try {
const purchaseReturn = await purchaseReturnsApi.create({
vendorId: selectedGrn.vendorId,
warehouseId: selectedGrn.warehouseId,
reasonCodeId,
lines: payloadLines,
})
toast.success("Purchase return posted", `${purchaseReturn.docNo}${purchaseReturn.ledgerRefs.length} ledger entr${purchaseReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
router.push("/dashboard/procurement/purchase-returns")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post purchase return", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !grns
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1>
<p className="text-base text-muted-foreground">Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Goods Receipt Note</Label>
<Select<number | null> value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a confirmed GRN" />
</SelectTrigger>
<SelectContent>
{(grns ?? []).map((g) => (
<SelectItem key={g.grnId} value={g.grnId} className="text-base">
{g.docNo} Vendor #{g.vendorId}, Warehouse #{g.warehouseId}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={setReasonCodeId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{reasonCodes.map((rc) => (
<SelectItem key={rc.reasonCodeId} value={rc.reasonCodeId} className="text-base">
{rc.description}
</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>
)}
{selectedGrn && (
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines received on {selectedGrn.docNo}</h2>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Received qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Return qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedGrn.lines.map((line) => {
const item = itemFor(line.itemId)
const state = lineState[line.grnLineId] ?? { selected: false, qty: "" }
const errors = lineErrors[line.grnLineId] ?? {}
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">
<Checkbox checked={state.selected} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={state.qty}
disabled={!state.selected}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(line.grnLineId, e.target.value)}
className="h-11 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</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/procurement/purchase-returns" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Return"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewPurchaseReturnPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewPurchaseReturnContent />
</Suspense>
)
}
@@ -0,0 +1,117 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { PackageX, Plus } from "lucide-react"
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { PurchaseReturnSummary } from "@/types/procurement"
import { Vendor, Warehouse } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function PurchaseReturnsListPage() {
const [returns, setReturns] = useState<PurchaseReturnSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")])
.then(([r, v, w, rc]) => {
setReturns(r.items)
setVendors(v.items)
setWarehouses(w.items)
setReasonCodes(rc.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
function vendorCode(id: number) {
return vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`
}
function warehouseCode(id: number) {
return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}`
}
function reasonLabel(id: number) {
return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}`
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Returns</h1>
<p className="text-base text-muted-foreground">Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).</p>
</div>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && returns === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && returns !== null && returns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageX className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No purchase returns yet.</p>
<Link href="/dashboard/procurement/purchase-returns/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Return
</Link>
</div>
)}
{!error && returns !== null && returns.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{returns.map((r) => (
<TableRow key={r.returnId}>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{vendorCode(r.vendorId)}</TableCell>
<TableCell className="px-3 py-3.5">{warehouseCode(r.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabel(r.reasonCodeId)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-success/10 px-2.5 text-sm text-success">
{r.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,144 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Requisition } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
export default function RequisitionDetailPage() {
const params = useParams<{ id: string }>()
const requisitionId = Number(params.id)
const [requisition, setRequisition] = useState<Requisition | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function load() {
setError(null)
requisitionsApi.get(requisitionId).then(setRequisition).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
if (!Number.isFinite(requisitionId)) return
load()
itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requisitionId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
async function handleSubmit() {
if (!requisition) return
setSubmitting(true)
try {
const updated = await requisitionsApi.submit(requisition.requisitionId)
setRequisition(updated)
toast.success("Requisition submitted", `${updated.docNo} is ready for RFQ or a direct PO.`)
} catch (err) {
toast.error("Could not submit requisition", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (error && !requisition) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!requisition) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
<RequisitionStatusBadge status={requisition.status} />
</div>
<p className="text-base text-muted-foreground">Requested by #{requisition.requestedBy} {new Date(requisition.createdAt).toLocaleString()}</p>
</div>
</div>
<div className="flex items-center gap-2">
{requisition.status === "Draft" && (
<Button size="lg" onClick={handleSubmit} disabled={submitting}>
<Send className="size-5" />
{submitting ? "Submitting…" : "Submit"}
</Button>
)}
{requisition.status === "Submitted" && (
<>
<Link
href={`/dashboard/procurement/rfqs/new?requisitionId=${requisition.requisitionId}`}
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<FileText className="size-5" />
Create RFQ
</Link>
<Link
href={`/dashboard/procurement/purchase-orders/new?requisitionId=${requisition.requisitionId}`}
className={cn(buttonVariants({ size: "lg" }))}
>
<ShoppingCart className="size-5" />
Create PO
</Link>
</>
)}
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Required by</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.reqLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.requiredBy}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,213 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateRequisitionLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreateReqLineInput } from "@/types/procurement"
import { ItemListItem } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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
itemId: number | null
qty: string
requiredBy: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `rline-${keySeq}`
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, qty: "", requiredBy: "" }
}
export default function NewRequisitionPage() {
const router = useRouter()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err)))
}, [])
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))
}
async function handleSubmit() {
setSubmitError(null)
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 = validateRequisitionLine({ itemId: line.itemId, qty: line.qty, requiredBy: line.requiredBy })
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: CreateReqLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
qty: Number(l.qty),
requiredBy: l.requiredBy,
}))
setSubmitting(true)
try {
const requisition = await requisitionsApi.create({ lines: payloadLines })
toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`)
router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create requisition", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !items
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1>
<p className="text-base text-muted-foreground">Request items for procurement; submit once the lines are ready (FR-PROC-01).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 w-full" />}
{!loading && (
<>
<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-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Required by</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
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">
<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="date"
value={line.requiredBy}
aria-invalid={!!errors.requiredBy}
onChange={(e) => updateLine(line.key, { requiredBy: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[errors.requiredBy ? { message: errors.requiredBy } : undefined]} />
</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/procurement/requisitions" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Requisition"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,151 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardList, Plus } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { errorMessage } from "@/lib/error-map"
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 10
export default function RequisitionsListPage() {
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
useEffect(() => setPage(1), [status])
function load() {
setError(null)
requisitionsApi
.list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
.then((res) => {
setRequisitions(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, status])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Requisitions</h1>
<p className="text-base text-muted-foreground">Raise a purchase requisition and submit it into procurement (FR-PROC-01).</p>
</div>
<Link href="/dashboard/procurement/requisitions/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Requisition
</Link>
</div>
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-12! w-56 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Submitted" className="text-base">Submitted</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && requisitions === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && requisitions !== null && requisitions.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No requisitions yet.</p>
<Link href="/dashboard/procurement/requisitions/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Requisition
</Link>
</div>
)}
{!error && requisitions !== null && requisitions.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisitions.map((r) => (
<TableRow key={r.requisitionId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/requisitions/${r.requisitionId}`} className="font-medium text-foreground hover:underline">
{r.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">
<RequisitionStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,332 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ShoppingCart } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateQuotationLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { QuotationLine, Rfq, RfqComparison } from "@/types/procurement"
import { ItemListItem, Vendor } 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"
import { RfqStatusBadge } from "@/components/procurement/status-badges"
interface QuoteDraft {
unitPrice: string
leadDays: string
}
export default function RfqDetailPage() {
const params = useParams<{ id: string }>()
const rfqId = Number(params.id)
const [rfq, setRfq] = useState<Rfq | null>(null)
const [comparison, setComparison] = useState<RfqComparison | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
const [quoteVendorId, setQuoteVendorId] = useState<number | null>(null)
const [quoteLines, setQuoteLines] = useState<Record<number, QuoteDraft>>({})
const [quoteErrors, setQuoteErrors] = useState<Record<number, Record<string, string>>>({})
const [quoteFormError, setQuoteFormError] = useState<string | null>(null)
const [submittingQuote, setSubmittingQuote] = useState(false)
function load() {
setError(null)
Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)])
.then(([r, c]) => {
setRfq(r)
setComparison(c)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
if (!Number.isFinite(rfqId)) return
load()
Promise.all([itemsApi.list({ pageSize: 200 }), vendorsApi.list({ pageSize: 200 })])
.then(([it, ve]) => {
setItems(it.items)
setVendors(ve.items)
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rfqId])
const quotedVendorIds = useMemo(() => {
const set = new Set<number>()
for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId)
return set
}, [comparison])
const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function vendorFor(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)
}
function selectQuoteVendor(vendorId: number | null) {
setQuoteVendorId(vendorId)
setQuoteFormError(null)
setQuoteErrors({})
if (!rfq) return
const draft: Record<number, QuoteDraft> = {}
for (const line of rfq.lines) draft[line.itemId] = { unitPrice: "", leadDays: "" }
setQuoteLines(draft)
}
async function handleSubmitQuote() {
if (!rfq || !quoteVendorId) {
setQuoteFormError("Select a vendor first.")
return
}
const nextErrors: Record<number, Record<string, string>> = {}
for (const line of rfq.lines) {
const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" }
const errors = validateQuotationLine(draft)
if (Object.keys(errors).length > 0) nextErrors[line.itemId] = errors
}
setQuoteErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setQuoteFormError("Fix the highlighted fields before submitting.")
return
}
const lines: QuotationLine[] = rfq.lines.map((l) => ({
itemId: l.itemId,
unitPrice: Number(quoteLines[l.itemId].unitPrice),
leadDays: Number(quoteLines[l.itemId].leadDays),
}))
setSubmittingQuote(true)
try {
await rfqsApi.addQuotation(rfqId, { vendorId: quoteVendorId, lines })
toast.success("Quotation recorded", `${vendorFor(quoteVendorId)?.code ?? `Vendor #${quoteVendorId}`} priced ${lines.length} line(s).`)
setQuoteVendorId(null)
setQuoteLines({})
const c = await rfqsApi.comparison(rfqId)
setComparison(c)
} catch (err) {
setQuoteFormError(errorMessage(err))
toast.error("Could not record quotation", errorMessage(err))
} finally {
setSubmittingQuote(false)
}
}
if (error && !rfq) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!rfq || !comparison) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
<RfqStatusBadge status={rfq.status} />
</div>
<p className="text-base text-muted-foreground">
{rfq.requisitionId ? `From Requisition #${rfq.requisitionId}` : ""}
Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")}
</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rfq.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.rfqLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<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) ? (
<p className="text-base text-muted-foreground">No quotations recorded yet.</p>
) : (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
{rfq.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) => {
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)
return (
<TableCell key={vid} className="px-3 py-3.5">
{cell ? (
<span>
{cell.unitPrice.toFixed(2)} <span className="text-sm text-muted-foreground">({cell.leadDays}d)</span>
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
{[...quotedVendorIds].length > 0 && (
<div className="flex flex-wrap gap-2">
{[...quotedVendorIds].map((vid) => (
<Link
key={vid}
href={`/dashboard/procurement/purchase-orders/new?rfqId=${rfqId}&vendorId=${vid}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ShoppingCart className="size-4" />
Create PO from {vendorFor(vid)?.code ?? `#${vid}`}
</Link>
))}
</div>
)}
</div>
{pendingVendors.length > 0 && (
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Record a quotation</h2>
<div className="flex flex-col gap-2 sm:w-72">
<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" />
</SelectTrigger>
<SelectContent>
{pendingVendors.map((vid) => (
<SelectItem key={vid} value={vid} className="text-base">
{vendorFor(vid)?.code ?? `#${vid}`} {vendorFor(vid)?.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{quoteVendorId && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-36 px-3 text-sm">Unit price</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Lead days</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rfq.lines.map((line) => {
const item = itemFor(line.itemId)
const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" }
const errors = quoteErrors[line.itemId] ?? {}
return (
<TableRow key={line.rfqLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="any"
value={draft.unitPrice}
aria-invalid={!!errors.unitPrice}
onChange={(e) => setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], unitPrice: e.target.value } }))}
className="h-11 text-base"
/>
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
min="0"
step="1"
value={draft.leadDays}
aria-invalid={!!errors.leadDays}
onChange={(e) => setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], leadDays: e.target.value } }))}
className="h-11 text-base"
/>
<FieldError errors={[errors.leadDays ? { message: errors.leadDays } : undefined]} />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
{quoteFormError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{quoteFormError}</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSubmitQuote} disabled={!quoteVendorId || submittingQuote}>
{submittingQuote ? "Saving…" : "Save quotation"}
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,280 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { requisitionsApi } from "@/lib/api/requisitions"
import { vendorsApi } from "@/lib/api/vendors"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateRfqLine } from "@/lib/validations/procurement"
import { cn } from "@/lib/utils"
import { CreateRfqLineInput } from "@/types/procurement"
import { ItemListItem, Vendor } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
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
itemId: number | null
qty: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `rfqline-${keySeq}`
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, qty: "" }
}
function NewRfqContent() {
const router = useRouter()
const searchParams = useSearchParams()
const requisitionId = Number(searchParams.get("requisitionId")) || null
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId)
const [vendorIds, setVendorIds] = useState<Set<number>>(new Set())
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })])
.then(([it, ve]) => {
setItems(it.items)
setVendors(ve.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (!requisitionId) return
requisitionsApi
.get(requisitionId)
.then((req) => {
setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) })))
})
.catch((err) => setHeaderError(errorMessage(err)))
.finally(() => setRequisitionLoading(false))
}, [requisitionId])
function toggleVendor(vendorId: number) {
setVendorIds((prev) => {
const next = new Set(prev)
if (next.has(vendorId)) next.delete(vendorId)
else next.add(vendorId)
return next
})
}
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))
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
if (vendorIds.size === 0) {
setHeaderError("Select at least one vendor to invite.")
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 = validateRfqLine({ itemId: line.itemId, qty: line.qty })
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: CreateRfqLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, qty: Number(l.qty) }))
setSubmitting(true)
try {
const rfq = await rfqsApi.create({ requisitionId, vendorIds: [...vendorIds], lines: payloadLines })
toast.success("RFQ created", `${rfq.docNo} sent to ${vendorIds.size} vendor(s).`)
router.push(`/dashboard/procurement/rfqs/${rfq.rfqId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create RFQ", errorMessage(err))
} finally {
setSubmitting(false)
}
}
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
}
const loading = !items || !vendors || requisitionLoading
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
<p className="text-base text-muted-foreground">
{requisitionId ? `Request quotations for Requisition #${requisitionId}` : "Request quotations from one or more vendors (FR-PROC-02)."}
</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-24 w-full" />}
{!loading && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base">Invite vendors</Label>
<div className="grid grid-cols-1 gap-1 rounded-xl border p-3 sm:grid-cols-2">
{(vendors ?? []).map((v) => (
<label key={v.vendorId} className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-muted/50">
<Checkbox checked={vendorIds.has(v.vendorId)} onCheckedChange={() => toggleVendor(v.vendorId)} />
<span className="text-base font-medium">{v.code}</span>
<span className="text-sm text-muted-foreground">{v.name}</span>
</label>
))}
</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>
{!requisitionId && (
<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-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
{!requisitionId && <TableHead className="h-12 w-10 px-3" />}
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => {
const errors = lineErrors[line.key] ?? {}
const item = itemFor(line.itemId)
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{requisitionId ? (
<div className="flex h-11 items-center text-base">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</div>
) : (
<>
<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">
<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>
{!requisitionId && (
<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/procurement/rfqs" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Sending…" : "Create RFQ"}
</Button>
</div>
</>
)}
</div>
)
}
export default function NewRfqPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewRfqContent />
</Suspense>
)
}
@@ -0,0 +1,104 @@
"use client"
import { useEffect, useState } from "react"
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"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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)
useEffect(() => {
Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })])
.then(([r, v]) => {
setRfqs(r.items)
setVendors(v.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">
<div>
<h1 className="text-2xl font-bold text-foreground">RFQs</h1>
<p className="text-base text-muted-foreground">Request quotations from vendors and compare pricing (FR-PROC-02).</p>
</div>
<Link href="/dashboard/procurement/rfqs/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New RFQ
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && rfqs === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rfqs !== null && rfqs.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No RFQs yet.</p>
<Link href="/dashboard/procurement/rfqs/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New RFQ
</Link>
</div>
)}
{!error && rfqs !== null && rfqs.length > 0 && (
<Table className="text-base">
<TableHeader>
<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>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rfqs.map((r) => (
<TableRow key={r.rfqId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/rfqs/${r.rfqId}`} className="font-medium text-foreground hover:underline">
{r.docNo}
</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">
<RfqStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,592 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { ApiError } from "@/lib/api-client"
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 { Badge } from "@/components/ui/badge"
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 ReorderDraft {
key: string
warehouseId: number | null
reorderPoint: string
reorderQty: string
}
interface ConversionDraft {
key: string
fromUom: number | null
toUom: number | null
factor: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `row-${keySeq}`
}
export default function ItemDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const itemId = Number(params.id)
const [item, setItem] = useState<Item | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([])
const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([])
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([])
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
// Basic info form
const [sku, setSku] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = 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 [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
// Reorder settings
const [reorderLines, setReorderLines] = useState<ReorderDraft[]>([])
const [reorderErrors, setReorderErrors] = useState<Record<string, Record<string, string>>>({})
const [reorderSaveError, setReorderSaveError] = useState<string | null>(null)
const [savingReorder, setSavingReorder] = useState(false)
// UOM conversions
const [conversionLines, setConversionLines] = useState<ConversionDraft[]>([])
const [conversionErrors, setConversionErrors] = useState<Record<string, Record<string, string>>>({})
const [conversionSaveError, setConversionSaveError] = useState<string | null>(null)
const [savingConversions, setSavingConversions] = useState(false)
function applyItem(data: Item) {
setItem(data)
setSku(data.sku)
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
setBaseUomId(data.baseUomId)
setDefaultVendorId(data.defaultVendorId)
setItemType(data.itemType)
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) })))
setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
}
function load() {
setLoadError(null)
itemsApi
.get(itemId)
.then(({ data, etag: tag }) => {
applyItem(data)
setEtag(tag)
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
}
useEffect(() => {
if (!Number.isFinite(itemId)) return
load()
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([cat, uo, ve, wh]) => {
setCategories(cat.items)
setUoms(uo.items)
setVendors(ve.items)
setWarehouses(wh.items)
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
async function handleSave() {
if (!item || !etag) return
setSaveError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
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 },
etag
)
applyItem(result.data)
setEtag(result.etag)
toast.success("Item saved", `${result.data.sku}${result.data.name}`)
} catch (err) {
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
return
}
const fe = fieldErrors(err)
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
setSaveError(errorMessage(err))
toast.error("Could not save item", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleToggleStatus() {
if (!item) return
const next = item.status === "Active" ? "Inactive" : "Active"
setTogglingStatus(true)
try {
await itemsApi.updateStatus(item.itemId, next)
toast.success(next === "Active" ? "Item activated" : "Item deactivated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingStatus(false)
}
}
function updateReorderLine(key: string, patch: Partial<ReorderDraft>) {
setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeReorderLine(key: string) {
setReorderLines((prev) => prev.filter((l) => l.key !== key))
}
async function handleSaveReorder() {
if (!item) return
setReorderSaveError(null)
const nextErrors: Record<string, Record<string, string>> = {}
for (const line of reorderLines) {
const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty })
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
}
setReorderErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setReorderSaveError("Fix the highlighted rows before saving.")
return
}
const settings: ItemReorderSetting[] = reorderLines.map((l) => ({
warehouseId: l.warehouseId as number,
reorderPoint: Number(l.reorderPoint),
reorderQty: Number(l.reorderQty),
}))
setSavingReorder(true)
try {
const result = await itemsApi.updateReorder(item.itemId, { settings })
setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev))
toast.success("Reorder settings saved")
} catch (err) {
setReorderSaveError(errorMessage(err))
toast.error("Could not save reorder settings", errorMessage(err))
} finally {
setSavingReorder(false)
}
}
function updateConversionLine(key: string, patch: Partial<ConversionDraft>) {
setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function removeConversionLine(key: string) {
setConversionLines((prev) => prev.filter((l) => l.key !== key))
}
async function handleSaveConversions() {
if (!item) return
setConversionSaveError(null)
const nextErrors: Record<string, Record<string, string>> = {}
for (const line of conversionLines) {
const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor })
if (Object.keys(errs).length > 0) nextErrors[line.key] = errs
}
setConversionErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
setConversionSaveError("Fix the highlighted rows before saving.")
return
}
const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) }))
setSavingConversions(true)
try {
const result = await itemsApi.updateUomConversions(item.itemId, { conversions })
setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev))
setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) })))
toast.success("UOM conversions saved")
} catch (err) {
setConversionSaveError(errorMessage(err))
toast.error("Could not save UOM conversions", errorMessage(err))
} finally {
setSavingConversions(false)
}
}
function uomName(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
if (loadError && !item) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "outline" }))}>
Back to items
</Link>
</div>
)
}
if (!item) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
<Badge
variant="outline"
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
>
{item.status}
</Badge>
</div>
<p className="text-base text-muted-foreground">{item.name}</p>
</div>
</div>
<Button size="lg" variant={item.status === "Active" ? "destructive" : "success"} onClick={handleToggleStatus} disabled={togglingStatus}>
{togglingStatus ? "Updating…" : item.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This item was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Basic info</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Description</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Default vendor</Label>
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{vendors.map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax class</Label>
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
<SelectItem value="Service" className="text-base">Service</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/products")}>
Back
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Reorder settings</h2>
<p className="text-sm text-muted-foreground">Per-warehouse reorder point and quantity (FR-MD-05).</p>
</div>
<Button type="button" variant="outline" onClick={() => setReorderLines((prev) => [...prev, { key: newKey(), warehouseId: null, reorderPoint: "", reorderQty: "" }])}>
<Plus className="size-5" />
Add row
</Button>
</div>
{reorderLines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder qty</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{reorderLines.map((line) => {
const errs = reorderErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.warehouseId}>
<SelectValue placeholder="Warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.warehouseId ? { message: errs.warehouseId } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.reorderPoint} aria-invalid={!!errs.reorderPoint} onChange={(e) => updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.reorderPoint ? { message: errs.reorderPoint } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.reorderQty} aria-invalid={!!errs.reorderQty} onChange={(e) => updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.reorderQty ? { message: errs.reorderQty } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeReorderLine(line.key)} aria-label="Remove row">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
{reorderSaveError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{reorderSaveError}</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSaveReorder} disabled={savingReorder}>
{savingReorder ? "Saving…" : "Save reorder settings"}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">UOM conversions</h2>
<p className="text-sm text-muted-foreground">Purchase/stock UOM base UOM conversion factors (FR-MD-02/03).</p>
</div>
<Button type="button" variant="outline" onClick={() => setConversionLines((prev) => [...prev, { key: newKey(), fromUom: null, toUom: item.baseUomId, factor: "" }])}>
<Plus className="size-5" />
Add row
</Button>
</div>
{conversionLines.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">From UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">To UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Factor</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{conversionLines.map((line) => {
const errs = conversionErrors[line.key] ?? {}
return (
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.fromUom}>
<SelectValue placeholder="From" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.fromUom ? { message: errs.fromUom } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errs.toUom}>
<SelectValue placeholder="To" />
</SelectTrigger>
<SelectContent>
{uoms.map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errs.toUom ? { message: errs.toUom } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input type="number" min="0" step="any" value={line.factor} aria-invalid={!!errs.factor} onChange={(e) => updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" />
<FieldError errors={[errs.factor ? { message: errs.factor } : undefined]} />
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeConversionLine(line.key)} aria-label="Remove row">
<Trash2 className="size-5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
{conversionSaveError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{conversionSaveError}</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={handleSaveConversions} disabled={savingConversions}>
{savingConversions ? "Saving…" : "Save conversions"}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."}
</p>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ListTree, Plus } from "lucide-react"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, CategoryTreeNode } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) {
return (
<div className="flex flex-col">
<div
className="flex items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/50"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
<ListTree className="size-4 text-muted-foreground" />
<span className="text-base font-medium text-foreground">{node.name}</span>
<span className="text-sm text-muted-foreground">#{node.categoryId}</span>
</div>
{node.children.map((child) => (
<TreeNode key={child.categoryId} node={child} depth={depth + 1} />
))}
</div>
)
}
export default function CategoriesPage() {
const [tree, setTree] = useState<CategoryTreeNode[] | null>(null)
const [flat, setFlat] = useState<Category[]>([])
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [parentId, setParentId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
setError(null)
Promise.all([categoriesApi.tree(), categoriesApi.list()])
.then(([t, f]) => {
setTree(t)
setFlat(f.items)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleCreate() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const category = await categoriesApi.create({ name, parentId })
toast.success("Category created", category.name)
setOpen(false)
setName("")
setParentId(null)
setErrors({})
load()
} catch (err) {
setErrors({ name: errorMessage(err) })
toast.error("Could not create category", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Hierarchical item category structure (FR-MD-04).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New Category</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New category</DialogTitle>
<DialogDescription>Optionally nest it under an existing category.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cat-parent">Parent (optional)</FieldLabel>
<Select<number | null> value={parentId} onValueChange={setParentId}>
<SelectTrigger id="cat-parent" className="w-full">
<SelectValue placeholder="None — top-level category" />
</SelectTrigger>
<SelectContent>
{flat.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
<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={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "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 && tree === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && tree !== null && tree.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ListTree className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No categories yet.</p>
</div>
)}
{!error && tree !== null && tree.length > 0 && (
<div className="flex flex-col rounded-xl border p-3">
{tree.map((node) => (
<TreeNode key={node.categoryId} node={node} depth={0} />
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,220 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { ItemType, TrackingMode } 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 { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function NewItemPage() {
const router = useRouter()
const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null)
const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null)
const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [sku, setSku] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<number | null>(null)
const [baseUomId, setBaseUomId] = useState<number | null>(null)
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [itemType, setItemType] = useState<ItemType>("Stocked")
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
const [taxClass, setTaxClass] = useState("STD")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })])
.then(([cat, uo, ve]) => {
setCategories(cat.items)
setUoms(uo.items)
setVendors(ve.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
async function handleSubmit() {
setSubmitError(null)
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const { data: item } = await itemsApi.create({
sku,
name,
description: description || null,
categoryId: categoryId as number,
baseUomId: baseUomId as number,
defaultVendorId,
itemType,
trackingMode,
taxClass: taxClass || null,
})
toast.success("Item created", `${item.sku}${item.name}`)
router.push(`/dashboard/products/${item.itemId}`)
} catch (err) {
const fe = fieldErrors(err)
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
setSubmitError(errorMessage(err))
toast.error("Could not create item", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !categories || !uoms || !vendors
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">New Item</h1>
<p className="text-base text-muted-foreground">SKU, category, base UOM, item type, and tracking mode (FR-MD-01).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-64 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">SKU</Label>
<Input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-12 text-base" />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null> value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{(categories ?? []).map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Base UOM</Label>
<Select<number | null> value={baseUomId} onValueChange={setBaseUomId}>
<SelectTrigger className="h-12! w-full text-base" aria-invalid={!!errors.baseUomId}>
<SelectValue placeholder="Select base UOM" />
</SelectTrigger>
<SelectContent>
{(uoms ?? []).map((u) => (
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.baseUomId ? { message: errors.baseUomId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Default vendor (optional)</Label>
<Select<number | null> value={defaultVendorId} onValueChange={setDefaultVendorId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax class (optional)</Label>
<Input value={taxClass} onChange={(e) => setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item type</Label>
<Select<ItemType> value={itemType} onValueChange={(v) => v && setItemType(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Stocked" className="text-base">Stocked</SelectItem>
<SelectItem value="NonStocked" className="text-base">Non-stocked</SelectItem>
<SelectItem value="Service" className="text-base">Service</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tracking mode</Label>
<Select<TrackingMode> value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
</div>
</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/products" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Item"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -1,7 +1,250 @@
export default function ProductsPage() {
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Category, ItemListItem, TrackingMode } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = EntityStatus | "All"
type TrackingFilter = TrackingMode | "All"
const PAGE_SIZE = 10
export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [categoryId, setCategoryId] = useState<number | "All">("All")
const [trackingMode, setTrackingMode] = useState<TrackingFilter>("All")
const [page, setPage] = useState(1)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, categoryId, trackingMode])
function load() {
setError(null)
itemsApi
.list({
page,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
categoryId: categoryId === "All" ? undefined : categoryId,
trackingMode: trackingMode === "All" ? undefined : trackingMode,
})
.then((res) => {
setItems(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, query, status, categoryId, trackingMode])
useEffect(() => {
categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {})
}, [])
function categoryName(id: number) {
return categories.find((c) => c.categoryId === id)?.name ?? `#${id}`
}
const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All"
return (
<div className="flex flex-1 items-center justify-center">
<h1 className="text-2xl font-semibold tracking-tight">Products</h1>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Items</h1>
<p className="text-base text-muted-foreground">Item master SKU, tracking mode, category, default vendor (FR-MD-01).</p>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Ruler className="size-5" />
UOMs
</Link>
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<ListTree className="size-5" />
Categories
</Link>
<Link href="/dashboard/products/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Item
</Link>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search SKU or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search items"
/>
</div>
<Select<number | "All"> value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.categoryId} value={c.categoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select<TrackingFilter> value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue placeholder="All tracking modes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All tracking modes</SelectItem>
<SelectItem value="None" className="text-base">None</SelectItem>
<SelectItem value="Batch" className="text-base">Batch</SelectItem>
<SelectItem value="Serial" className="text-base">Serial</SelectItem>
</SelectContent>
</Select>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && items === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && items !== null && items.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Package className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No items match your search/filter." : "No items yet."}</p>
{!hasFilters && (
<Link href="/dashboard/products/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Item
</Link>
)}
</div>
)}
{!error && items !== null && items.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">SKU</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Category</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.itemId}>
<TableCell className="px-3 py-3.5 font-medium">
<Link href={`/dashboard/products/${item.itemId}`} className="hover:underline">
{item.sku}
</Link>
</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.trackingMode}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{item.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/products/${item.itemId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Edit ${item.sku}`}
>
<Pencil className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,134 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Ruler } from "lucide-react"
import { uomsApi } from "@/lib/api/uoms"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Uom } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function UomsPage() {
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
async function handleCreate() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const uom = await uomsApi.create({ name })
toast.success("UOM created", uom.name)
setOpen(false)
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.name) setErrors({ name: fe.name })
toast.error("Could not create UOM", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New UOM</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>New UOM</DialogTitle>
<DialogDescription>e.g. EA, KG, Box-12.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="uom-name">Name</FieldLabel>
<Input id="uom-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Box-12" 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={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "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 && uoms === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && uoms !== null && uoms.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Ruler className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No UOMs yet.</p>
</div>
)}
{!error && uoms !== null && uoms.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uoms.map((u) => (
<TableRow key={u.uomId}>
<TableCell className="px-3 py-3.5 font-medium">{u.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,444 @@
"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>
)
}
@@ -0,0 +1,233 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } 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 { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
const grnId = Number(params.id)
const [grn, setGrn] = useState<Grn | null>(null)
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [bins, setBins] = useState<Bin[]>([])
const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false)
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
const idempotencyKey = useRef(crypto.randomUUID())
useEffect(() => {
if (!Number.isFinite(grnId)) return
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()])
.then(([it, uo]) => {
setItems(it.items)
setUoms(uo.items)
})
.catch(() => {})
}, [grnId])
useEffect(() => {
if (!grn) return
warehousesApi.listBins(grn.warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [grn?.warehouseId])
function itemFor(itemId: number) {
return items.find((i) => i.itemId === itemId)
}
function uomFor(uomId: number) {
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
}
function binFor(binId: number | null) {
if (!binId) return "—"
return bins.find((b) => b.binId === binId)?.code ?? `#${binId}`
}
async function handleConfirm() {
if (!grn) return
setError(null)
setConfirming(true)
try {
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
setConfirmResult(result)
setGrn((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not confirm GRN", errorMessage(err))
} finally {
setConfirming(false)
}
}
async function handleRelease(grnLineId: number, action: "Release" | "Reject") {
if (!grn) return
setError(null)
setReleasingLineId(grnLineId)
try {
const result = await grnsApi.releaseLine(grn.grnId, grnLineId, action)
setGrn((prev) =>
prev
? { ...prev, lines: prev.lines.map((l) => (l.grnLineId === grnLineId ? { ...l, holdStatus: result.holdStatus } : l)) }
: prev
)
toast.success(action === "Release" ? "Line released" : "Line rejected", `Hold status is now ${result.holdStatus}.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not update line", errorMessage(err))
} finally {
setReleasingLineId(null)
}
}
if (error && !grn) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!grn) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
<GrnStatusBadge status={grn.status} />
</div>
<p className="text-base text-muted-foreground">
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} Vendor #{grn.vendorId} Warehouse #{grn.warehouseId}
</p>
</div>
</div>
{grn.status === "Draft" && (
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
<PackageCheck className="size-5" />
{confirming ? "Confirming…" : "Confirm GRN"}
</Button>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{confirmResult && (
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">Confirmed stock layers created (FR-GRN-06)</p>
</div>
<div className="text-sm text-muted-foreground">
Layers: {confirmResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {confirmResult.ledgerRefs.join(", ")}</div>
{confirmResult.poStatus && <div className="text-sm text-muted-foreground">PO status: {confirmResult.poStatus}</div>}
</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Received value</TableHead>
<TableHead className="h-12 px-3 text-sm">Hold status</TableHead>
{grn.status === "Confirmed" && <TableHead className="h-12 px-3 text-sm">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{grn.lines.map((line) => {
const item = itemFor(line.itemId)
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{uomFor(line.uomId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">{line.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{line.receivedValue.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
</TableCell>
{grn.status === "Confirmed" && (
<TableCell className="px-3 py-3.5">
{line.holdStatus === "OnHold" ? (
<div className="flex items-center gap-1">
<Button
type="button"
variant="success"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Release")}
>
<ShieldAlert className="size-4" />
Release
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={releasingLineId === line.grnLineId}
onClick={() => handleRelease(line.grnLineId, "Reject")}
>
<XCircle className="size-4" />
Reject
</Button>
</div>
) : line.holdStatus === "Rejected" ? (
<Link
href={`/dashboard/procurement/purchase-returns/new?grnId=${grn.grnId}&grnLineId=${line.grnLineId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<PackageX className="size-4" />
Create Return
</Link>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,555 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
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, grnHeaderSchema } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
import { Bin, ItemListItem, Uom, Vendor, 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"
type Mode = "po" | "direct"
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 `gline-${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 NewGrnPage() {
const router = useRouter()
const [mode, setMode] = useState<Mode>("po")
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [uoms, setUoms] = useState<Uom[] | null>(null)
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderSummary[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [vendorId, setVendorId] = useState<number | null>(null)
const [poId, setPoId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
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(() => {
Promise.all([
warehousesApi.list(),
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list(),
vendorsApi.list({ pageSize: 200, status: "Active" }),
purchaseOrdersApi.list({ pageSize: 200 }),
])
.then(([wh, it, uo, ve, po]) => {
setWarehouses(wh.items)
setItems(it.items)
setUoms(uo.items)
setVendors(ve.items)
setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived"))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
function switchMode(next: Mode) {
setMode(next)
setPoId(null)
setVendorId(null)
setLines([emptyLine()])
setHeaderError(null)
setLineErrors({})
}
async function handleSelectPo(nextPoId: number | null) {
setPoId(nextPoId)
if (!nextPoId) {
setLines([emptyLine()])
return
}
setPoLoading(true)
setHeaderError(null)
try {
const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId)
const openLines = po.lines.filter((l) => l.qtyReceived < l.qty)
if (openLines.length === 0) {
setHeaderError("This purchase order has no open (unreceived) lines.")
setLines([])
return
}
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
setLines(
openLines.map(
(l): DraftLine => ({
key: newKey(),
poLineId: l.poLineId,
itemId: l.itemId,
uomId: l.uomId,
binId: null,
qty: String(l.qty - l.qtyReceived),
unitCost: String(l.unitPrice),
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
})
)
)
} catch (err) {
setHeaderError(errorMessage(err))
} finally {
setPoLoading(false)
}
}
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() {
setSubmitError(null)
setHeaderError(null)
const header = grnHeaderSchema.safeParse({
warehouseId: warehouseId ?? 0,
vendorId: mode === "direct" ? vendorId : null,
poId: mode === "po" ? poId : null,
})
if (!header.success) {
setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.")
return
}
if (mode === "direct" && !vendorId) {
setHeaderError("Select a vendor for a direct receipt.")
return
}
if (mode === "po" && !poId) {
setHeaderError("Select a purchase order.")
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 grn = await grnsApi.create({
poId: mode === "po" ? poId : null,
vendorId: mode === "direct" ? vendorId : null,
warehouseId: warehouseId as number,
lines: payloadLines,
})
toast.success("GRN created", `${grn.docNo} is ready to confirm.`)
router.push(`/dashboard/receiving/grn/${grn.grnId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create GRN", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !warehouses || !items || !uoms || !vendors || !purchaseOrders
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/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">New GRN</h1>
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="flex w-fit gap-2 rounded-full border border-input bg-muted/40 p-1">
<Button
type="button"
size="sm"
variant={mode === "po" ? "default" : "ghost"}
className="rounded-full"
onClick={() => switchMode("po")}
>
Against PO
</Button>
<Button
type="button"
size="sm"
variant={mode === "direct" ? "default" : "ghost"}
className="rounded-full"
onClick={() => switchMode("direct")}
>
Direct receipt
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{mode === "po" && (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Purchase order</Label>
<Select<number | null> value={poId} onValueChange={(v) => handleSelectPo(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select an approved/partially received PO" />
</SelectTrigger>
<SelectContent>
{(purchaseOrders ?? []).map((p) => (
<SelectItem key={p.poId} value={p.poId} className="text-base">
{p.docNo} Vendor #{p.vendorId} ({p.status})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{mode === "direct" && (
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Vendor</Label>
<Select<number | null> value={vendorId} onValueChange={(v) => setVendorId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
<SelectContent>
{(vendors ?? []).map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.code} {v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">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>
{mode === "direct" && (
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, emptyLine()])}>
<Plus className="size-5" />
Add line
</Button>
)}
</div>
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && 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">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
</div>
) : (
<>
<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">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
</div>
) : (
<>
<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" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,313 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Pencil, Plus, Search, Trash2 } 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"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 5
export default function GrnListPage() {
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
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(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
// Any filter change starts back at page 1.
useEffect(() => {
setPage(1)
}, [query, status])
function load() {
setError(null)
grnsApi
.list({
page,
pageSize: PAGE_SIZE,
q: query || undefined,
status: status === "All" ? undefined : status,
})
.then((res) => {
setGrns(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Goods Receipt Notes</h1>
<p className="text-base text-muted-foreground">
Receive goods against a purchase order, or record a direct receipt.
</p>
</div>
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New GRN
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search doc no., PO, vendor, warehouse…"
className="h-14 w-full pl-11 text-base"
aria-label="Search GRNs"
/>
</div>
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="Confirmed" className="text-base">Confirmed</SelectItem>
<SelectItem value="Closed" className="text-base">Closed</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button
type="button"
variant="ghost"
onClick={() => {
setSearchInput("")
setStatus("All")
}}
>
Clear filters
</Button>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">
{error}
</div>
)}
{!error && grns === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && grns !== null && grns.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageSearch className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No GRNs match your search/filter." : "No GRNs yet. Create one from an approved purchase order."}
</p>
{hasFilters ? (
<Button
type="button"
variant="outline"
size="lg"
onClick={() => {
setSearchInput("")
setStatus("All")
}}
>
Clear filters
</Button>
) : (
<Link href="/dashboard/receiving/grn/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New GRN
</Link>
)}
</div>
)}
{!error && grns !== null && grns.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">PO</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grns.map((grn) => {
const isDraft = grn.status === "Draft"
return (
<TableRow key={grn.grnId}>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className="font-medium text-foreground hover:underline"
>
{grn.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{grn.poId ?? <span className="text-muted-foreground">Direct</span>}</TableCell>
<TableCell className="px-3 py-3.5">{grn.vendorId}</TableCell>
<TableCell className="px-3 py-3.5">{grn.warehouseId}</TableCell>
<TableCell className="px-3 py-3.5">
<GrnStatusBadge status={grn.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Link
href={`/dashboard/receiving/grn/${grn.grnId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${grn.docNo}`}
>
<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>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,270 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock"
import { ItemListItem, 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
itemId: number | null
qtyDelta: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `aline-${keySeq}`
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, qtyDelta: "" }
}
export default function NewAdjustmentPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [submitError, setSubmitError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<StockAdjustment | null>(null)
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
setWarehouses(wh.items)
setItems(it.items)
setReasonCodes(rc.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
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.length > 1 ? prev.filter((l) => l.key !== key) : prev))
}
async function handleSubmit() {
setSubmitError(null)
if (!warehouseId) {
setSubmitError("Select a warehouse.")
return
}
if (!reasonCodeId) {
setSubmitError("A reason code is required for adjustments.")
return
}
const nextLineErrors: Record<string, string> = {}
for (const line of lines) {
if (!line.itemId) nextLineErrors[line.key] = "Select an item"
else if (!line.qtyDelta || Number(line.qtyDelta) === 0) nextLineErrors[line.key] = "Enter a non-zero quantity change"
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
qtyDelta: Number(l.qtyDelta),
}))
setSubmitting(true)
try {
const adjustment = await stockAdjustmentsApi.create({ warehouseId, reasonCodeId, lines: payloadLines })
setResult(adjustment)
toast.success("Adjustment posted", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not post adjustment", errorMessage(err))
} finally {
setSubmitting(false)
}
}
function startAnother() {
setResult(null)
setLines([emptyLine()])
setSubmitError(null)
}
const loading = !warehouses || !items || !reasonCodes
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1>
<p className="text-base text-muted-foreground">Posts immediately on creation (FR-STK-07) a reason code is mandatory.</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-12 w-full" />}
{!loading && result && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">{result.docNo} posted</p>
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
</div>
<div className="flex gap-3">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to list
</Link>
<Button size="lg" type="button" onClick={startAnother}>
New adjustment
</Button>
</div>
</div>
)}
{!loading && !result && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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 className="flex flex-col gap-2">
<Label className="text-base">Reason code</Label>
<Select<number | null> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
{(reasonCodes ?? []).map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
{r.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-72 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Qty change</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<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">
<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>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
step="any"
placeholder="e.g. -15 or 50"
value={line.qtyDelta}
aria-invalid={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qtyDelta: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
</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/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Posting…" : "Post Adjustment"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,107 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { StockAdjustmentSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
export default function AdjustmentsListPage() {
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [reasonLabels, setReasonLabels] = useState<Map<number, string>>(new Map())
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockAdjustmentsApi.list({ pageSize: 50 }), warehousesApi.list(), reasonCodesApi.list("Adjustment")])
.then(([a, wh, rc]) => {
setAdjustments(a.items)
setWarehouses(wh.items)
setReasonLabels(new Map(rc.items.map((r) => [r.reasonCodeId, r.description])))
})
.catch((err) => setError(errorMessage(err)))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
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/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
</div>
</div>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Adjustment
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && adjustments === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && adjustments !== null && adjustments.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<SlidersHorizontal className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No adjustments yet.</p>
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Adjustment
</Link>
</div>
)}
{!error && adjustments !== null && adjustments.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adjustments.map((a) => (
<TableRow key={a.adjustmentId}>
<TableCell className="px-3 py-3.5 font-medium">{a.docNo}</TableCell>
<TableCell className="px-3 py-3.5">{whCode(a.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<AdjustmentStatusBadge status={a.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,170 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PostCountResponse, StockCount } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
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"
import { CountStatusBadge } from "@/components/stock/status-badges"
export default function CountDetailPage() {
const params = useParams<{ id: string }>()
const countId = Number(params.id)
const [count, setCount] = useState<StockCount | null>(null)
const [error, setError] = useState<string | null>(null)
const [drafts, setDrafts] = useState<Record<number, string>>({})
const [saving, setSaving] = useState(false)
const [posting, setPosting] = useState(false)
const [postResult, setPostResult] = useState<PostCountResponse | null>(null)
useEffect(() => {
if (!Number.isFinite(countId)) return
stockCountsApi
.get(countId)
.then((c) => {
setCount(c)
setDrafts(Object.fromEntries(c.lines.map((l) => [l.countLineId, l.countedQty !== null ? String(l.countedQty) : ""])))
})
.catch((err) => setError(errorMessage(err)))
}, [countId])
async function handleSave() {
if (!count) return
setError(null)
setSaving(true)
try {
const lines = Object.entries(drafts)
.filter(([, v]) => v !== "")
.map(([countLineId, v]) => ({ countLineId: Number(countLineId), countedQty: Number(v) }))
const result = await stockCountsApi.enterCounts(countId, { lines })
setCount((prev) => (prev ? { ...prev, lines: result.lines } : prev))
toast.success("Counts saved", "Variances recalculated.")
} catch (err) {
setError(errorMessage(err))
toast.error("Could not save counts", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handlePost() {
setError(null)
setPosting(true)
try {
const result = await stockCountsApi.post(countId)
setPostResult(result)
setCount((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("Count posted", `Variance adjustment ${result.adjustmentId} created.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not post count", errorMessage(err))
} finally {
setPosting(false)
}
}
if (error && !count) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!count) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
<CountStatusBadge status={count.status} />
</div>
<p className="text-base text-muted-foreground">{count.countType} count · Warehouse #{count.warehouseId}</p>
</div>
</div>
{count.status === "Draft" && (
<div className="flex gap-3">
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save counts"}
</Button>
<Button size="lg" onClick={handlePost} disabled={posting}>
<ClipboardCheck className="size-5" />
{posting ? "Posting…" : "Post count"}
</Button>
</div>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{postResult && (
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">Posted variance adjustment #{postResult.adjustmentId} created</p>
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">System qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Counted qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Variance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{count.lines.map((line) => (
<TableRow key={line.countLineId}>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status === "Draft" ? (
<Input
type="number"
step="any"
value={drafts[line.countLineId] ?? ""}
onChange={(e) => setDrafts((prev) => ({ ...prev, [line.countLineId]: e.target.value }))}
className="h-11 w-32 text-base"
/>
) : (
(line.countedQty ?? "—")
)}
</TableCell>
<TableCell className={cn("px-3 py-3.5 font-medium", line.variance && line.variance !== 0 && (line.variance > 0 ? "text-success" : "text-destructive"))}>
{line.variance ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,162 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { CountType } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function NewCountPage() {
const router = useRouter()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [countType, setCountType] = useState<CountType>("Cycle")
const [selectedItemIds, setSelectedItemIds] = useState<Set<number>>(new Set())
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" })])
.then(([wh, it]) => {
setWarehouses(wh.items)
setItems(it.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
function toggleItem(itemId: number) {
setSelectedItemIds((prev) => {
const next = new Set(prev)
if (next.has(itemId)) next.delete(itemId)
else next.add(itemId)
return next
})
}
async function handleSubmit() {
setSubmitError(null)
if (!warehouseId) {
setSubmitError("Select a warehouse.")
return
}
if (selectedItemIds.size === 0) {
setSubmitError("Select at least one item to count.")
return
}
setSubmitting(true)
try {
const count = await stockCountsApi.create({ warehouseId, countType, itemIds: [...selectedItemIds] })
toast.success("Count created", `${count.docNo} — system quantities snapshotted.`)
router.push(`/dashboard/stock/counts/${count.countId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create count", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !warehouses || !items
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Count</h1>
<p className="text-base text-muted-foreground">System quantities are snapshotted immediately; enter counted quantities next.</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-12 w-full" />}
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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 className="flex flex-col gap-2">
<Label className="text-base">Count type</Label>
<Select<CountType> value={countType} onValueChange={(v) => setCountType(v ?? "Cycle")}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Cycle" className="text-base">Cycle (partial, scheduled)</SelectItem>
<SelectItem value="Full" className="text-base">Full physical count</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-3">
<Label className="text-base">Items to count</Label>
<div className="flex flex-col gap-1 rounded-xl border p-3">
{(items ?? []).map((item) => (
<label key={item.itemId} className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-muted/50">
<Checkbox checked={selectedItemIds.has(item.itemId)} onCheckedChange={() => toggleItem(item.itemId)} />
<span className="text-base font-medium">{item.sku}</span>
<span className="text-sm text-muted-foreground">{item.name}</span>
</label>
))}
</div>
</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/stock/counts" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Count"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,108 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ClipboardList, Plus } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges"
export default function CountsListPage() {
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockCountsApi.list({ pageSize: 50 }), warehousesApi.list()])
.then(([c, wh]) => {
setCounts(c.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
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/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1>
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
</div>
</div>
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Count
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && counts === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && counts !== null && counts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No counts yet.</p>
<Link href="/dashboard/stock/counts/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Count
</Link>
</div>
)}
{!error && counts !== null && counts.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{counts.map((c) => (
<TableRow key={c.countId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/stock/counts/${c.countId}`} className="font-medium text-primary hover:underline">
{c.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{whCode(c.warehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{c.countType}</TableCell>
<TableCell className="px-3 py-3.5">
<CountStatusBadge status={c.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,164 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function StockEnquiryPage() {
const [rows, setRows] = useState<OnHand[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
useEffect(() => {
Promise.all([stockApi.onHandList(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([r, it, wh]) => {
setRows(r)
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const filtered = useMemo(() => {
if (!rows) return null
const term = search.trim().toLowerCase()
return rows
.filter((r) => warehouseId === "All" || r.warehouseId === warehouseId)
.filter((r) => {
if (!term) return true
const item = itemsById.get(r.itemId)
const haystack = `${item?.sku ?? ""} ${item?.name ?? ""}`.toLowerCase()
return haystack.includes(term)
})
.sort((a, b) => a.itemId - b.itemId || a.warehouseId - b.warehouseId)
}, [rows, search, warehouseId, itemsById])
const loading = rows === null || items === null || warehouses === null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1>
<p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by SKU or item name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search items"
/>
</div>
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageSearch className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No stock matches your search/filter.</p>
</div>
)}
{!loading && !error && filtered !== null && filtered.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">On hand</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">On hold</TableHead>
<TableHead className="h-12 px-3 text-sm">In transit</TableHead>
<TableHead className="h-12 px-3 text-sm">Reserved</TableHead>
<TableHead className="h-12 px-3 text-sm">Valuation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row) => {
const item = itemsById.get(row.itemId)
const wh = warehousesById.get(row.warehouseId)
return (
<TableRow key={`${row.itemId}-${row.warehouseId}`}>
<TableCell className="px-3 py-3.5">
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${row.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5">{row.onHand}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.available}</TableCell>
<TableCell className="px-3 py-3.5">{row.onHold}</TableCell>
<TableCell className="px-3 py-3.5">{row.inTransit}</TableCell>
<TableCell className="px-3 py-3.5">{row.reserved}</TableCell>
<TableCell className="px-3 py-3.5">
<Link
href={`/dashboard/stock/valuation?itemId=${row.itemId}&warehouseId=${row.warehouseId}`}
className="font-medium text-primary hover:underline"
>
View
</Link>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,240 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { LedgerEntry } from "@/types/stock"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, 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 { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE = 10
export default function StockLedgerPage() {
const [entries, setEntries] = useState<LedgerEntry[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [itemId, setItemId] = useState<number | "All">("All")
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [page, setPage] = useState(1)
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([it, wh]) => {
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
setPage(1)
}, [itemId, warehouseId, from, to])
useEffect(() => {
let cancelled = false
stockApi
.ledger({
itemId: itemId === "All" ? undefined : itemId,
warehouseId: warehouseId === "All" ? undefined : warehouseId,
from: from || undefined,
to: to || undefined,
page,
pageSize: PAGE_SIZE,
})
.then((res) => {
if (cancelled) return
setEntries(res.items)
setPagination(res.pagination)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [itemId, warehouseId, from, to, page])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const loading = entries === null || items === null || warehouses === null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1>
<p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label className="text-base">Item</Label>
<Select<number | "All"> value={itemId} onValueChange={(v) => setItemId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All items</SelectItem>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
</div>
</div>
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && entries !== null && entries.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ScrollText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No ledger entries match this filter.</p>
</div>
)}
{!loading && !error && entries !== null && entries.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Direction</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Value</TableHead>
<TableHead className="h-12 px-3 text-sm">Running balance</TableHead>
<TableHead className="h-12 px-3 text-sm">Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => {
const item = itemsById.get(entry.itemId)
const wh = warehousesById.get(entry.warehouseId)
return (
<TableRow key={entry.ledgerId}>
<TableCell className="px-3 py-3.5">{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{item?.sku ?? `#${entry.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-20 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{entry.qtyBase}</TableCell>
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,98 @@
import Link from "next/link"
import {
AlertOctagon,
AlertTriangle,
ArrowLeftRight,
BadgeDollarSign,
ClipboardList,
PackageSearch,
ScrollText,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Stock Enquiry",
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
href: "/dashboard/stock/enquiry",
icon: PackageSearch,
},
{
title: "Stock Ledger",
description: "The immutable, append-only movement journal — every in/out with running balance.",
href: "/dashboard/stock/ledger",
icon: ScrollText,
},
{
title: "Valuation",
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
href: "/dashboard/stock/valuation",
icon: BadgeDollarSign,
},
{
title: "Transfers",
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
href: "/dashboard/stock/transfers",
icon: ArrowLeftRight,
},
{
title: "Adjustments",
description: "Increase, decrease, or write off stock with a mandatory reason code.",
href: "/dashboard/stock/adjustments",
icon: SlidersHorizontal,
},
{
title: "Counts",
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
href: "/dashboard/stock/counts",
icon: ClipboardList,
},
{
title: "Reorder Alerts",
description: "Items at or below their reorder point, with a one-click requisition.",
href: "/dashboard/stock/reorder-alerts",
icon: AlertTriangle,
},
{
title: "Wastage",
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
href: "/dashboard/stock/wastage",
icon: AlertOctagon,
},
]
export default function StockHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Management</h1>
<p className="text-base text-muted-foreground">
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,140 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
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 ReorderAlertsPage() {
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [requesting, setRequesting] = useState<string | null>(null)
const [requested, setRequested] = useState<Set<string>>(new Set())
useEffect(() => {
Promise.all([stockApi.reorderAlerts(), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([a, it, wh]) => {
setAlerts(a.items)
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
async function handleRequisition(alert: ReorderAlert) {
const key = `${alert.itemId}-${alert.warehouseId}`
setRequesting(key)
try {
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.`)
} catch (err) {
toast.error("Could not create requisition", errorMessage(err))
} finally {
setRequesting(null)
}
}
const loading = !alerts || !items || !warehouses
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1>
<p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && alerts.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<CheckCircle2 className="size-12 text-success" />
<p className="text-base text-muted-foreground">Nothing is below its reorder point right now.</p>
</div>
)}
{!loading && !error && alerts.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Available</TableHead>
<TableHead className="h-12 px-3 text-sm">Reorder point</TableHead>
<TableHead className="h-12 px-3 text-sm">Suggested qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => {
const key = `${alert.itemId}-${alert.warehouseId}`
const item = itemsById.get(alert.itemId)
const wh = warehousesById.get(alert.warehouseId)
const done = requested.has(key)
return (
<TableRow key={key}>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 text-warning" />
<div>
<div className="font-medium">{item?.sku ?? `#${alert.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</div>
</div>
</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${alert.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5 font-medium text-destructive">{alert.available}</TableCell>
<TableCell className="px-3 py-3.5">{alert.reorderPoint}</TableCell>
<TableCell className="px-3 py-3.5">{alert.suggestedRequisitionQty}</TableCell>
<TableCell className="px-3 py-3.5">
<Button
type="button"
size="sm"
variant={done ? "outline" : "default"}
disabled={done || requesting === key}
onClick={() => handleRequisition(alert)}
>
{done ? "Requisitioned" : requesting === key ? "Creating…" : "Create requisition"}
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,170 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { TransferStatusBadge } from "@/components/stock/status-badges"
export default function TransferDetailPage() {
const params = useParams<{ id: string }>()
const transferId = Number(params.id)
const [transfer, setTransfer] = useState<StockTransfer | null>(null)
const [error, setError] = useState<string | null>(null)
const [dispatching, setDispatching] = useState(false)
const [receiving, setReceiving] = useState(false)
const [dispatchResult, setDispatchResult] = useState<DispatchTransferResponse | null>(null)
const [receiveResult, setReceiveResult] = useState<ReceiveTransferResponse | null>(null)
useEffect(() => {
if (!Number.isFinite(transferId)) return
stockTransfersApi.get(transferId).then(setTransfer).catch((err) => setError(errorMessage(err)))
}, [transferId])
async function handleDispatch() {
setError(null)
setDispatching(true)
try {
const result = await stockTransfersApi.dispatch(transferId)
setDispatchResult(result)
setTransfer((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("Transfer dispatched", `${result.consumedLayers.length} layer(s) consumed at source.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not dispatch transfer", errorMessage(err))
} finally {
setDispatching(false)
}
}
async function handleReceive() {
if (!transfer) return
setError(null)
setReceiving(true)
try {
const result = await stockTransfersApi.receive(
transferId,
transfer.lines.map((l) => ({ transferLineId: l.transferLineId, qty: l.qty }))
)
setReceiveResult(result)
setTransfer((prev) => (prev ? { ...prev, status: result.status } : prev))
toast.success("Transfer received", `${result.createdLayers.length} layer(s) created at destination.`)
} catch (err) {
setError(errorMessage(err))
toast.error("Could not receive transfer", errorMessage(err))
} finally {
setReceiving(false)
}
}
if (error && !transfer) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!transfer) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
<TransferStatusBadge status={transfer.status} />
</div>
<p className="text-base text-muted-foreground">
Warehouse #{transfer.srcWarehouseId} Warehouse #{transfer.destWarehouseId}
</p>
</div>
</div>
<div className="flex gap-3">
{transfer.status === "Draft" && (
<Button size="lg" onClick={handleDispatch} disabled={dispatching}>
<Truck className="size-5" />
{dispatching ? "Dispatching…" : "Dispatch"}
</Button>
)}
{transfer.status === "InTransit" && (
<Button size="lg" onClick={handleReceive} disabled={receiving}>
<PackageCheck className="size-5" />
{receiving ? "Receiving…" : "Receive"}
</Button>
)}
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{dispatchResult && (
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">Dispatched source layers consumed (cost-preserving, FR-STK-06)</p>
</div>
<div className="text-sm text-muted-foreground">
Consumed: {dispatchResult.consumedLayers.map((l) => `#${l.layerId} (${l.qtyConsumed} @ ${l.unitCost})`).join(", ")}
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {dispatchResult.ledgerRefs.join(", ")}</div>
</div>
)}
{receiveResult && (
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">Received destination layers created</p>
</div>
<div className="text-sm text-muted-foreground">
Created: {receiveResult.createdLayers.map((l) => `#${l.layerId} (${l.qtyReceived} @ ${l.unitCost})`).join(", ")}
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {receiveResult.ledgerRefs.join(", ")}</div>
</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Src bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Dest bin</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfer.lines.map((line) => (
<TableRow key={line.transferLineId}>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,298 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { CreateTransferLineInput } from "@/types/stock"
import { Bin, ItemListItem, 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
itemId: number | null
destBinId: number | null
qty: string
}
let keySeq = 0
function newKey() {
keySeq += 1
return `tline-${keySeq}`
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, destBinId: null, qty: "" }
}
export default function NewTransferPage() {
const router = useRouter()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [srcBins, setSrcBins] = useState<Bin[]>([])
const [destBins, setDestBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [srcWarehouseId, setSrcWarehouseId] = useState<number | null>(null)
const [destWarehouseId, setDestWarehouseId] = useState<number | null>(null)
const [srcBinId, setSrcBinId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [submitError, setSubmitError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" })])
.then(([wh, it]) => {
setWarehouses(wh.items)
setItems(it.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (!srcWarehouseId) {
setSrcBins([])
return
}
warehousesApi.listBins(srcWarehouseId).then((r) => setSrcBins(r.items)).catch(() => setSrcBins([]))
}, [srcWarehouseId])
useEffect(() => {
if (!destWarehouseId) {
setDestBins([])
return
}
warehousesApi.listBins(destWarehouseId).then((r) => setDestBins(r.items)).catch(() => setDestBins([]))
}, [destWarehouseId])
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.length > 1 ? prev.filter((l) => l.key !== key) : prev))
}
async function handleSubmit() {
setSubmitError(null)
if (!srcWarehouseId || !destWarehouseId) {
setSubmitError("Select both a source and destination warehouse.")
return
}
if (srcWarehouseId === destWarehouseId) {
setSubmitError("Source and destination warehouses must be different.")
return
}
const nextLineErrors: Record<string, string> = {}
for (const line of lines) {
if (!line.itemId) nextLineErrors[line.key] = "Select an item"
else if (!line.qty || Number(line.qty) <= 0) nextLineErrors[line.key] = "Quantity must be greater than 0"
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateTransferLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
srcBinId,
destBinId: l.destBinId,
qty: Number(l.qty),
}))
setSubmitting(true)
try {
const transfer = await stockTransfersApi.create({ srcWarehouseId, destWarehouseId, lines: payloadLines })
toast.success("Transfer created", `${transfer.docNo} is ready to dispatch.`)
router.push(`/dashboard/stock/transfers/${transfer.transferId}`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not create transfer", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !warehouses || !items
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1>
<p className="text-base text-muted-foreground">Create a transfer, then dispatch and receive it (FR-STK-05).</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <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">From warehouse</Label>
<Select<number | null> value={srcWarehouseId} onValueChange={(v) => setSrcWarehouseId(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 className="flex flex-col gap-2">
<Label className="text-base">From bin (optional)</Label>
<Select<number | null> value={srcBinId} onValueChange={(v) => setSrcBinId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Any bin" />
</SelectTrigger>
<SelectContent>
{srcBins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
{b.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">To warehouse</Label>
<Select<number | null> value={destWarehouseId} onValueChange={(v) => setDestWarehouseId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{(warehouses ?? [])
.filter((w) => w.warehouseId !== srcWarehouseId)
.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-64 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-40 px-3 text-sm">Destination bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<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">
<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>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{destBins.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={!!lineErrors[line.key]}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
</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/stock/transfers" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create Transfer"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,108 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { StockTransferSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { TransferStatusBadge } from "@/components/stock/status-badges"
export default function TransfersListPage() {
const [transfers, setTransfers] = useState<StockTransferSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
Promise.all([stockTransfersApi.list({ pageSize: 50 }), warehousesApi.list()])
.then(([t, wh]) => {
setTransfers(t.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
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/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1>
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
</div>
</div>
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Transfer
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && transfers === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && transfers !== null && transfers.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ArrowLeftRight className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No transfers yet.</p>
<Link href="/dashboard/stock/transfers/new" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Plus className="size-5" />
New Transfer
</Link>
</div>
)}
{!error && transfers !== null && transfers.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">From</TableHead>
<TableHead className="h-12 px-3 text-sm">To</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers.map((t) => (
<TableRow key={t.transferId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/stock/transfers/${t.transferId}`} className="font-medium text-primary hover:underline">
{t.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{whCode(t.srcWarehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">{whCode(t.destWarehouseId)}</TableCell>
<TableCell className="px-3 py-3.5">
<TransferStatusBadge status={t.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,180 @@
"use client"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Valuation } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
function ValuationContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [valuation, setValuation] = useState<Valuation | null>(null)
const [error, setError] = useState<string | null>(null)
const itemId = Number(searchParams.get("itemId")) || null
const warehouseId = Number(searchParams.get("warehouseId")) || null
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([it, wh]) => {
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
if (!itemId || !warehouseId) {
setValuation(null)
return
}
setError(null)
stockApi
.valuation(itemId, warehouseId)
.then(setValuation)
.catch((err) => setError(errorMessage(err)))
}, [itemId, warehouseId])
const item = useMemo(() => (items ?? []).find((i) => i.itemId === itemId), [items, itemId])
function setParam(key: "itemId" | "warehouseId", value: number) {
const params = new URLSearchParams(searchParams.toString())
params.set(key, String(value))
router.push(`/dashboard/stock/valuation?${params.toString()}`)
}
const loading = items === null || warehouses === null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Valuation</h1>
<p className="text-base text-muted-foreground">FIFO cost-layer breakdown and total stock value (FR-STK-04).</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{loading ? (
<Skeleton className="h-12 w-full" />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label className="text-base">Item</Label>
<Select<number | null> value={itemId} onValueChange={(v) => v && setParam("itemId", v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select an item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => v && setParam("warehouseId", v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a 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>
)}
{!itemId || !warehouseId ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BadgeDollarSign className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">Select an item and warehouse to see its FIFO valuation.</p>
</div>
) : valuation === null ? (
<Skeleton className="h-48 w-full" />
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Item</p>
<p className="text-lg font-semibold">{item?.sku ?? `#${itemId}`}</p>
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total quantity</p>
<p className="text-lg font-semibold">{valuation.totalQty}</p>
</div>
<div className="rounded-xl border p-5">
<p className="text-sm text-muted-foreground">Total value ({valuation.currency})</p>
<p className="text-lg font-semibold">{valuation.totalValue.toFixed(2)}</p>
</div>
</div>
{valuation.layers.length === 0 ? (
<p className="text-base text-muted-foreground">No open FIFO layers for this item/warehouse.</p>
) : (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Layer</TableHead>
<TableHead className="h-12 px-3 text-sm">Receipt date</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 px-3 text-sm">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{valuation.layers.map((layer) => (
<TableRow key={layer.layerId}>
<TableCell className="px-3 py-3.5">#{layer.layerId}</TableCell>
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5">{layer.value.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
</div>
)
}
export default function StockValuationPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<ValuationContent />
</Suspense>
)
}
@@ -0,0 +1,246 @@
"use client"
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 { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReasonCode, StockAdjustment } from "@/types/stock"
import { Bin, ItemListItem, 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 { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function NewWastagePage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
const [bins, setBins] = useState<Bin[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [itemId, setItemId] = useState<number | null>(null)
const [binId, setBinId] = useState<number | null>(null)
const [qty, setQty] = useState("")
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<StockAdjustment | null>(null)
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)))
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (!warehouseId) {
setBins([])
return
}
warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([]))
}, [warehouseId])
async function handleSubmit() {
setSubmitError(null)
const nextErrors: Record<string, string> = {}
if (!warehouseId) nextErrors.warehouseId = "Select a warehouse"
if (!itemId) nextErrors.itemId = "Select an item"
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const adjustment = await wastageApi.record({
warehouseId: warehouseId as number,
itemId: itemId as number,
binId,
qty: Number(qty),
reasonCodeId: reasonCodeId as number,
})
setResult(adjustment)
toast.success("Wastage recorded", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not record wastage", errorMessage(err))
} finally {
setSubmitting(false)
}
}
function startAnother() {
setResult(null)
setItemId(null)
setQty("")
setReasonCodeId(null)
setSubmitError(null)
}
const loading = !warehouses || !items || !reasonCodes
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) a reason code is mandatory.</p>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{loading && !loadError && <Skeleton className="h-12 w-full" />}
{!loading && result && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">{result.docNo} posted</p>
</div>
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
</div>
<div className="flex gap-3">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to wastage report
</Link>
<Button size="lg" type="button" onClick={startAnother}>
Record another
</Button>
</div>
</div>
)}
{!loading && !result && (
<>
{(reasonCodes ?? []).length === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
<AlertOctagon className="size-5" />
No wastage-type reason codes (Damage / Theft-Loss / Expiry Write-off) are configured.
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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>
<FieldError errors={[errors.warehouseId ? { message: errors.warehouseId } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Bin (optional)</Label>
<Select<number | null> value={binId} onValueChange={(v) => setBinId(v)}>
<SelectTrigger className="h-12! 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>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Item</Label>
<Select<number | null> value={itemId} onValueChange={(v) => setItemId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<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]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Quantity wasted</Label>
<Input
type="number"
min="0"
step="any"
value={qty}
aria-invalid={!!errors.qty}
onChange={(e) => setQty(e.target.value)}
className="h-12 text-base"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label className="text-base">Reason</Label>
<Select<number | null> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select a wastage reason" />
</SelectTrigger>
<SelectContent>
{(reasonCodes ?? []).map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
{r.description}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.reasonCodeId ? { message: errors.reasonCodeId } : undefined]} />
</div>
</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/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" variant="destructive" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Recording…" : "Record Wastage"}
</Button>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,197 @@
"use client"
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 { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReasonCode } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export default function WastagePage() {
const [records, setRecords] = useState<WastageRecord[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [reasonCodeId, setReasonCodeId] = useState<number | "All">("All")
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)))
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
}, [])
useEffect(() => {
wastageApi
.list({
warehouseId: warehouseId === "All" ? undefined : warehouseId,
reasonCodeId: reasonCodeId === "All" ? undefined : reasonCodeId,
})
.then(setRecords)
.catch((err) => setError(errorMessage(err)))
}, [warehouseId, reasonCodeId])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const reasonById = useMemo(() => new Map((reasonCodes ?? []).map((r) => [r.reasonCodeId, r])), [reasonCodes])
const totals = useMemo(() => {
if (!records) return { qty: 0, value: 0 }
return records.reduce(
(acc, r) => ({ qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }),
{ qty: 0, value: 0 }
)
}, [records])
const loading = !records || !reasonCodes || !items || !warehouses
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/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Wastage</h1>
<p className="text-base text-muted-foreground">
Damage, theft/loss, and expiry write-offs posted as reason-coded stock adjustments (FR-STK-07).
</p>
</div>
</div>
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
Record Wastage
</Link>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!loading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
<p className="text-sm text-muted-foreground">Total quantity wasted</p>
<p className="text-2xl font-semibold text-destructive">{totals.qty}</p>
</div>
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
<p className="text-sm text-muted-foreground">Total value wasted (LKR)</p>
<p className="text-2xl font-semibold text-destructive">{totals.value.toFixed(2)}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | "All"> value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code} {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Reason</Label>
<Select<number | "All"> value={reasonCodeId} onValueChange={(v) => setReasonCodeId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All reasons</SelectItem>
{(reasonCodes ?? []).map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-base">
{r.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && records.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<AlertOctagon className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No wastage recorded for this filter.</p>
</div>
)}
{!loading && !error && records.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
<TableHead className="h-12 px-3 text-sm">Qty wasted</TableHead>
<TableHead className="h-12 px-3 text-sm">Value (LKR)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => {
const item = itemsById.get(r.itemId)
const wh = warehousesById.get(r.warehouseId)
const reason = reasonById.get(r.reasonCodeId)
return (
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`}>
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{r.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="font-medium">{item?.sku ?? `#${r.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</TableCell>
<TableCell className="px-3 py-3.5">{wh?.code ?? `#${r.warehouseId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-6 w-fit justify-center border-transparent bg-destructive/10 px-2.5 text-sm text-destructive">
{reason?.description ?? `#${r.reasonCodeId}`}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium text-destructive">-{r.qty}</TableCell>
<TableCell className="px-3 py-3.5">{r.value.toFixed(2)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { ApiError } from "@/lib/api-client"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Vendor } 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 { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function VendorDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const vendorId = Number(params.id)
const [vendor, setVendor] = useState<Vendor | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [togglingStatus, setTogglingStatus] = useState(false)
function load() {
setLoadError(null)
vendorsApi
.get(vendorId)
.then(({ data, etag: tag }) => {
setVendor(data)
setEtag(tag)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
}
useEffect(() => {
if (Number.isFinite(vendorId)) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vendorId])
async function handleSave() {
setSaveError(null)
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0 || !etag) return
setSaving(true)
try {
const result = await vendorsApi.update(vendorId, { code, name, terms: terms || null, taxReg: taxReg || null, currency }, etag)
setVendor(result.data)
setEtag(result.etag)
toast.success("Vendor saved", `${result.data.code}${result.data.name}`)
} catch (err) {
const code = err instanceof ApiError ? err.code : (err as { code?: string })?.code
if (code === "CONCURRENCY_CONFLICT") {
setConflict(true)
setSaveError(errorMessage(err))
setSaving(false)
return
}
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
setSaveError(errorMessage(err))
toast.error("Could not save vendor", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleToggleStatus() {
if (!vendor) return
const next = vendor.status === "Active" ? "Inactive" : "Active"
setTogglingStatus(true)
try {
await vendorsApi.updateStatus(vendorId, next)
toast.success(next === "Active" ? "Vendor activated" : "Vendor deactivated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setTogglingStatus(false)
}
}
if (loadError && !vendor) {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "outline" }))}>
Back to vendors
</Link>
</div>
)
}
if (!vendor) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/vendors" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{vendor.code}</h1>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
vendor.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{vendor.status}
</Badge>
</div>
<p className="text-base text-muted-foreground">{vendor.name}</p>
</div>
</div>
<Button
size="lg"
variant={vendor.status === "Active" ? "destructive" : "success"}
onClick={handleToggleStatus}
disabled={togglingStatus}
>
{togglingStatus ? "Updating…" : vendor.status === "Active" ? "Deactivate" : "Activate"}
</Button>
</div>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
<div className="flex flex-col gap-2">
<p>{saveError ?? "This vendor was changed by someone else."} Reload before retrying.</p>
<Button size="sm" variant="outline" onClick={load}>
Reload
</Button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label className="text-base">Code</Label>
<Input value={code} onChange={(e) => setCode(e.target.value)} aria-invalid={!!errors.code} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Payment terms</Label>
<Input value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax registration</Label>
<Input value={taxReg} onChange={(e) => setTaxReg(e.target.value)} className="h-12 text-base" disabled={conflict} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} maxLength={3} aria-invalid={!!errors.currency} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/vendors")}>
Cancel
</Button>
<Button size="lg" onClick={handleSave} disabled={saving || conflict}>
<Save className="size-5" />
{saving ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
)
}
+385
View File
@@ -0,0 +1,385 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 5
export default function VendorsPage() {
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("LKR")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [query, status])
function load() {
vendorsApi
.list({ q: query || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE })
.then((res) => {
setVendors(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [query, status, page])
function resetForm() {
setCode("")
setName("")
setTerms("")
setTaxReg("")
setCurrency("LKR")
setErrors({})
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const result = await vendorsApi.create({ code, name, terms: terms || null, taxReg: taxReg || null, currency })
toast.success("Vendor created", `${result.data.code}${result.data.name}`)
setOpen(false)
resetForm()
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
toast.error("Could not create vendor", errorMessage(err))
} finally {
setSubmitting(false)
}
}
// FR-MD-08: masters are deactivated, never hard-deleted — "Delete" in the UI
// maps to PATCH status → Inactive, same mechanism as the detail page's toggle.
async function handleDeactivate(vendor: Vendor) {
setActionPendingId(vendor.vendorId)
try {
await vendorsApi.updateStatus(vendor.vendorId, "Inactive")
toast.success("Vendor deleted", `${vendor.code} has been deactivated.`)
load()
} catch (err) {
toast.error("Could not delete vendor", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
const hasFilters = query.length > 0 || status !== "All"
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Vendors</h1>
<p className="text-base text-muted-foreground">Supplier master data code, terms, tax registration, currency (FR-MD-06).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" />
New Vendor
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New vendor</DialogTitle>
<DialogDescription>Create a supplier record.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="v-code">Code</FieldLabel>
<Input id="v-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VN-005" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="v-name">Name</FieldLabel>
<Input id="v-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="v-terms">Payment terms (optional)</FieldLabel>
<Input id="v-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="v-taxreg">Tax registration (optional)</FieldLabel>
<Input id="v-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="v-currency">Currency</FieldLabel>
<Input id="v-currency" value={currency} onChange={(e) => setCurrency(e.target.value)} placeholder="LKR" maxLength={3} aria-invalid={!!errors.currency} />
<FieldError errors={[errors.currency ? { message: errors.currency } : 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={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search code or name…"
className="h-14 w-full pl-11 text-base"
aria-label="Search vendors"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full flex-1 basis-0 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && vendors === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && vendors !== null && vendors.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Truck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No vendors match your search/filter." : "No vendors yet."}
</p>
</div>
)}
{!error && vendors !== null && vendors.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Terms</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendors.map((v) => (
<TableRow key={v.vendorId}>
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell>
<TableCell className="px-3 py-3.5">{v.name}</TableCell>
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{v.currency}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Dialog>
<DialogTrigger
render={<Button variant="ghost" size="icon-sm" aria-label={`View ${v.code}`} />}
>
<Eye className="size-4" />
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>{v.code}</DialogTitle>
<DialogDescription>{v.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-base">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Payment terms</span>
<span className="font-medium">{v.terms ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Tax registration</span>
<span className="font-medium">{v.taxReg ?? "—"}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Currency</span>
<span className="font-medium">{v.currency}</span>
</div>
<div className="flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground">Status</span>
<Badge
variant="outline"
className={cn(
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
v.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
)}
>
{v.status}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Created</span>
<span className="font-medium">{new Date(v.createdAt).toLocaleString()}</span>
</div>
</div>
<div className="flex justify-center pt-2">
<DialogClose render={<Button variant="outline" className="min-w-36" />}>Close</DialogClose>
</div>
</DialogContent>
</Dialog>
<Link
href={`/dashboard/vendors/${v.vendorId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Update ${v.code}`}
>
<Pencil className="size-4" />
</Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
aria-label={`Delete ${v.code}`}
disabled={v.status === "Inactive" || actionPendingId === v.vendorId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${v.code}?`}
description={`This deactivates ${v.name} (FR-MD-08 — master data is deactivated, not hard-deleted). It can be reactivated later from its detail page.`}
confirmLabel="Delete"
onConfirm={() => handleDeactivate(v)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,168 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Bin, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function WarehouseDetailPage() {
const params = useParams<{ id: string }>()
const warehouseId = Number(params.id)
const [warehouse, setWarehouse] = useState<Warehouse | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [binType, setBinType] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function loadBins() {
warehousesApi.listBins(warehouseId).then((res) => setBins(res.items)).catch((err) => setError(errorMessage(err)))
}
useEffect(() => {
if (!Number.isFinite(warehouseId)) return
warehousesApi.get(warehouseId).then(setWarehouse).catch((err) => setError(errorMessage(err)))
loadBins()
}, [warehouseId])
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Bin code is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null })
toast.success("Bin created", bin.code)
setOpen(false)
setCode("")
setBinType("")
setErrors({})
loadBins()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
toast.error("Could not create bin", errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (error && !warehouse) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!warehouse || !bins) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-72" />
<Skeleton className="h-48 w-full" />
</div>
)
}
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/warehouse" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" />
New Bin
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New bin</DialogTitle>
<DialogDescription>Add a bin/location to {warehouse.code}.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="bin-code">Code</FieldLabel>
<Input id="bin-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="bin-type">Bin type (optional)</FieldLabel>
<Input id="bin-type" value={binType} onChange={(e) => setBinType(e.target.value)} placeholder="Shelf, Pallet, …" />
</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={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "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>
)}
{bins.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<MapPinned className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No bins yet in this warehouse.</p>
</div>
) : (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bins.map((b) => (
<TableRow key={b.binId}>
<TableCell className="px-3 py-3.5 font-medium">{b.code}</TableCell>
<TableCell className="px-3 py-3.5">{b.binType ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,178 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Bin, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function WarehousesPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
warehousesApi
.list()
.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))
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
function binCount(warehouseId: number) {
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
}
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Warehouse code is required"
if (!name.trim()) nextErrors.name = "Warehouse name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code, name })
toast.success("Warehouse created", `${warehouse.code}${warehouse.name}`)
setOpen(false)
setCode("")
setName("")
setErrors({})
load()
} catch (err) {
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
toast.error("Could not create warehouse", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !warehouses || !bins
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" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" />
New Warehouse
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New warehouse</DialogTitle>
<DialogDescription>Create a new warehouse. Bins are added from its detail page.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="wh-code">Code</FieldLabel>
<Input id="wh-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="wh-name">Name</FieldLabel>
<Input id="wh-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Main Warehouse - Negombo" 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={handleCreate} disabled={submitting}>
{submitting ? "Creating…" : "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>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && warehouses.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<WarehouseIcon className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No warehouses yet.</p>
</div>
)}
{!loading && !error && warehouses.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{warehouses.map((w) => (
<TableRow key={w.warehouseId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/warehouse/${w.warehouseId}`} className="font-medium text-primary hover:underline">
{w.code}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}