serial numbers

This commit is contained in:
2026-10-07 14:00:32 +05:30
committed by ImanThiyanga
parent 3abcfa6e6f
commit a788349138
89 changed files with 15320 additions and 216 deletions
@@ -44,6 +44,7 @@ import {
Receipt,
ReceiptText,
Scale,
ScanLine,
ScrollText,
Settings,
ShieldCheck,
@@ -60,6 +61,9 @@ import {
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
const SERIALS_HREF = "/dashboard/stock/serials"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
@@ -148,6 +152,9 @@ const navItems: {
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
@@ -427,6 +434,8 @@ export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const { navCodes, loading } = useAuth()
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
const { serialNumbersEnabled } = useTrackingSettings()
// While /auth/me hasn't resolved yet, show nothing rather than briefly
// flashing the full menu to a restricted role. Once resolved, a nav item
@@ -452,9 +461,10 @@ export function AppSidebar() {
)
.map((item) => ({
...item,
children: bypassCodes.has(item.code)
children: (bypassCodes.has(item.code)
? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)),
}))
// Close mobile menu on route change
@@ -30,6 +30,9 @@ const SEGMENT_LABELS: Record<string, string> = {
counts: "Counts",
"reorder-alerts": "Reorder Alerts",
wastage: "Wastage",
serials: "Serials",
batches: "Batch Expiry",
warranties: "Warranties",
procurement: "Procurement",
requisitions: "Requisitions",
rfqs: "RFQs",
@@ -88,6 +88,8 @@ const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock/reorder-alerts": "Reorder Alerts",
"/dashboard/stock/wastage": "Wastage",
"/dashboard/stock/wastage/new": "Record Wastage",
"/dashboard/stock/serials": "Serials",
"/dashboard/stock/batches": "Batch Expiry",
}
const PRODUCTION_TITLES: Record<string, string> = {
@@ -101,6 +103,7 @@ const PRODUCTION_TITLES: Record<string, string> = {
function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
@@ -0,0 +1,227 @@
"use client"
import { useEffect, useState } from "react"
import { Layers, ScanLine } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { AvailableBatch, AvailableSerial, SalesLineSerial } from "@/types/tracking"
import { TrackingMode } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export interface LineTrackingValue {
batchId: number | null
batchNo: string | null
serials: SalesLineSerial[]
}
interface Props {
itemId: number
warehouseId: number | null
trackingMode: TrackingMode | undefined
/** Units the line issues (qty + free qty) — a serial line needs exactly this many serials. */
units: number
value: LineTrackingValue
onChange: (next: LineTrackingValue) => void
disabled?: boolean
}
/**
* Batch / serial choice for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 8).
* Serial items: pick exactly `units` in-stock serials (search, scan + Enter, or tick).
* Batch items: pin a batch, or leave "Auto (FEFO)" for posting to pick soonest-expiring first.
* Renders nothing for untracked items.
*/
export function LineTrackingPicker({ itemId, warehouseId, trackingMode, units, value, onChange, disabled }: Props) {
const { serialNumbersEnabled } = useTrackingSettings()
if (!itemId || !warehouseId || (trackingMode !== "Serial" && trackingMode !== "Batch")) return null
// Serial numbers switched off in Product Configuration: serial items are plain stock.
if (trackingMode === "Serial" && serialNumbersEnabled === false) return null
return trackingMode === "Serial" ? (
<SerialPicker itemId={itemId} warehouseId={warehouseId} units={units} value={value} onChange={onChange} disabled={disabled} />
) : (
<BatchPicker itemId={itemId} warehouseId={warehouseId} value={value} onChange={onChange} disabled={disabled} />
)
}
function SerialPicker({
itemId, warehouseId, units, value, onChange, disabled,
}: { itemId: number; warehouseId: number; units: number; value: LineTrackingValue; onChange: (v: LineTrackingValue) => void; disabled?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [available, setAvailable] = useState<AvailableSerial[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
let cancelled = false
trackingApi
.availableSerials(itemId, warehouseId)
.then((rows) => { if (!cancelled) { setAvailable(rows); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [open, itemId, warehouseId])
const selected = value.serials
const selectedIds = new Set(selected.map((s) => s.serialId))
const complete = selected.length === units
function toggle(s: { serialId: number; serialNo: string }) {
const next = selectedIds.has(s.serialId)
? selected.filter((x) => x.serialId !== s.serialId)
: [...selected, { serialId: s.serialId, serialNo: s.serialNo }]
onChange({ ...value, serials: next })
}
// Scan / type a serial and press Enter: exact (case-insensitive) match ticks it.
function addTyped() {
const term = query.trim().toLowerCase()
if (!term || !available) return
const match = available.find((s) => s.serialNo.toLowerCase() === term)
if (!match) {
setError(`Serial "${query.trim()}" is not in stock at this warehouse.`)
return
}
if (!selectedIds.has(match.serialId)) toggle(match)
setQuery("")
setError(null)
}
const shown = (available ?? []).filter((s) => !query.trim() || s.serialNo.toLowerCase().includes(query.trim().toLowerCase()))
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className={cn("mt-1 h-7 gap-1 px-2 text-xs", complete ? "border-success/50 text-success" : "border-warning/60 text-warning")}
/>
}
>
<ScanLine className="size-3.5" />
Serials {selected.length}/{units}
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-3">
<div className="flex flex-col gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addTyped()
}
}}
placeholder="Scan or search a serial — Enter to add"
className="h-9 text-sm"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<div className="max-h-60 overflow-y-auto rounded-md border">
{available === null && !error && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
{available !== null && shown.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">No in-stock serials match.</p>
)}
{shown.map((s) => {
const checked = selectedIds.has(s.serialId)
const full = !checked && selected.length >= units
return (
<label
key={s.serialId}
className={cn("flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50", full && "cursor-not-allowed opacity-50")}
>
<Checkbox checked={checked} disabled={full} onCheckedChange={() => toggle(s)} />
<span className="font-mono">{s.serialNo}</span>
{s.batchNo && <span className="ml-auto text-xs text-muted-foreground">{s.batchNo}</span>}
</label>
)
})}
</div>
{selected.length > 0 && (
<Button type="button" variant="ghost" size="sm" className="self-end text-xs" onClick={() => onChange({ ...value, serials: [] })}>
Clear selection
</Button>
)}
</div>
</PopoverContent>
</Popover>
)
}
function BatchPicker({
itemId, warehouseId, value, onChange, disabled,
}: { itemId: number; warehouseId: number; value: LineTrackingValue; onChange: (v: LineTrackingValue) => void; disabled?: boolean }) {
const [open, setOpen] = useState(false)
const [batches, setBatches] = useState<AvailableBatch[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
let cancelled = false
trackingApi
.availableBatches(itemId, warehouseId)
.then((rows) => { if (!cancelled) { setBatches(rows); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [open, itemId, warehouseId])
function pick(batch: AvailableBatch | null) {
onChange({ ...value, batchId: batch?.batchId ?? null, batchNo: batch?.batchNo ?? null })
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button type="button" variant="outline" size="sm" disabled={disabled} className="mt-1 h-7 gap-1 px-2 text-xs" />}
>
<Layers className="size-3.5" />
{value.batchId ? `Batch ${value.batchNo ?? value.batchId}` : "Batch: auto (FEFO)"}
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<button
type="button"
onClick={() => pick(null)}
className={cn("flex w-full flex-col rounded-md px-3 py-2 text-left hover:bg-muted/50", !value.batchId && "bg-muted")}
>
<span className="text-sm font-medium">Auto (FEFO)</span>
<span className="text-xs text-muted-foreground">Soonest-expiring batches are issued first when posted.</span>
</button>
{error && <p className="px-3 py-2 text-xs text-destructive">{error}</p>}
{batches === null && !error && <p className="px-3 py-2 text-xs text-muted-foreground">Loading…</p>}
{batches?.filter((b) => b.batchId !== null).map((b) => (
<button
key={b.batchId}
type="button"
onClick={() => pick(b)}
className={cn("flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left hover:bg-muted/50", value.batchId === b.batchId && "bg-muted")}
>
<span className="flex flex-col">
<span className="font-mono text-sm">{b.batchNo}</span>
<span className={cn("text-xs", b.daysToExpiry !== null && b.daysToExpiry <= 30 ? "text-warning" : "text-muted-foreground")}>
{b.expiryDate ? `Expires ${b.expiryDate}${b.daysToExpiry !== null ? ` (${b.daysToExpiry}d)` : ""}` : "No expiry"}
</span>
</span>
<span className="text-xs tabular-nums text-muted-foreground">{b.qty}</span>
</button>
))}
{batches !== null && batches.every((b) => b.batchId === null) && (
<p className="px-3 py-2 text-xs text-muted-foreground">No batches in stock at this warehouse.</p>
)}
</PopoverContent>
</Popover>
)
}
@@ -8,9 +8,10 @@ import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine, SalesLineAllocation } from "@/types/sales"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -25,6 +26,10 @@ import { toast } from "@/components/ui/toast"
interface LineState {
selected: boolean
qty: string
/** Serial line: the sold serials being returned (qty follows the count). */
serialIds?: number[]
/** Line that issued several batches: which one comes back. */
batchId?: number | null
}
export interface SalesReturnFormProps {
@@ -43,7 +48,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [allocationsByLine, setAllocationsByLine] = useState<Record<number, SalesLineAllocation[]>>({})
const [remainingLoading, setRemainingLoading] = useState(false)
const [markDamaged, setMarkDamaged] = useState(false)
const { serialNumbersEnabled } = useTrackingSettings()
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
@@ -83,8 +91,13 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
.getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => {
const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty
const allocations: Record<number, SalesLineAllocation[]> = {}
for (const row of rows) {
map[row.salesInvoiceLineId] = row.remainingQty
allocations[row.salesInvoiceLineId] = row.allocations ?? []
}
setRemainingByLine(map)
setAllocationsByLine(allocations)
if (presetLineId) {
const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
@@ -115,6 +128,74 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
}
/** Sold serials of a line not yet returned — non-empty means the line is serial-tracked. */
function returnableSerials(lineId: number) {
if (serialNumbersEnabled === false) return [] // switched off: serial items return as plain stock
return (allocationsByLine[lineId] ?? []).filter((a) => a.serialId !== null && a.qtyReturned < a.qty)
}
/** Distinct batches a line issued that still have returnable qty. */
function returnableBatches(lineId: number) {
const seen = new Map<number, SalesLineAllocation>()
for (const a of allocationsByLine[lineId] ?? [])
if (a.batchId !== null && a.serialId === null && a.qtyReturned < a.qty && !seen.has(a.batchId)) seen.set(a.batchId, a)
return [...seen.values()]
}
function toggleSerial(lineId: number, serialId: number) {
setLineState((prev) => {
const current = prev[lineId]?.serialIds ?? []
const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId]
return { ...prev, [lineId]: { ...prev[lineId], selected: next.length > 0, serialIds: next, qty: String(next.length) } }
})
}
/** Serial checkboxes or batch choice under a line, when the sale recorded either. */
function renderLineTracking(line: SalesInvoiceLine) {
const lineId = line.salesInvoiceLineId
const serials = returnableSerials(lineId)
const batches = returnableBatches(lineId)
const state = lineState[lineId]
if (serials.length > 0) {
return (
<div className="mt-2 flex flex-col gap-1">
<p className="text-xs text-muted-foreground">Tick the serials coming back:</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{serials.map((s) => (
<label key={s.serialId} className="flex items-center gap-1.5 text-sm">
<Checkbox checked={state?.serialIds?.includes(s.serialId!) ?? false} onCheckedChange={() => toggleSerial(lineId, s.serialId!)} />
<span className="font-mono">{s.serialNo}</span>
</label>
))}
</div>
</div>
)
}
if (batches.length > 1 && state?.selected) {
return (
<div className="mt-2 max-w-64">
<Select<number | null>
value={state.batchId ?? null}
onValueChange={(v) => setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], batchId: v } }))}
>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Which batch comes back?" />
</SelectTrigger>
<SelectContent>
{batches.map((b) => (
<SelectItem key={b.batchId} value={b.batchId} className="text-sm">
{b.batchNo}{b.expiryDate ? ` · exp ${b.expiryDate}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
if (batches.length === 1) return <p className="mt-1 text-xs text-muted-foreground">Batch {batches[0].batchNo}</p>
return null
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
@@ -141,6 +222,11 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
})
const state = lineState[line.salesInvoiceLineId]
if (returnableSerials(line.salesInvoiceLineId).length > 0 && (state.serialIds?.length ?? 0) !== Number(state.qty))
errors.qty = "Tick one serial per unit returned"
if (returnableBatches(line.salesInvoiceLineId).length > 1 && !state.batchId)
errors.qty = "Choose which batch comes back"
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
}
setLineErrors(nextErrors)
@@ -153,6 +239,8 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty),
batchId: lineState[l.salesInvoiceLineId].batchId ?? null,
serialIds: lineState[l.salesInvoiceLineId].serialIds?.length ? lineState[l.salesInvoiceLineId].serialIds : null,
}))
setSubmitting(true)
@@ -161,6 +249,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId,
reasonCodeId,
markDamaged: markDamaged || null,
lines: payloadLines,
})
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
@@ -216,6 +305,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={markDamaged} onCheckedChange={(v) => setMarkDamaged(!!v)} />
Returned serials are damaged (not resaleable) — Defective / Quality Reject reasons imply this
</label>
</div>
{headerError && (
@@ -242,6 +335,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
{item ? `${item.sku} — ${item.name}` : line.description}
</span>
</label>
{renderLineTracking(line)}
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div>
@@ -305,7 +399,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
<TableCell>
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell>
<TableCell>{item ? `${item.sku} — ${item.name}` : line.description}</TableCell>
<TableCell>
{item ? `${item.sku} — ${item.name}` : line.description}
{renderLineTracking(line)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining}
@@ -17,6 +17,8 @@ export interface SuggestOption {
/** Optional pill shown on the right, before the hint (e.g. an available stock count). */
badge?: string
badgeTone?: SuggestBadgeTone
/** Optional highlighted pills right after the label (e.g. "Serial" / "Batch" tracking). */
tags?: { label: string; tone: SuggestTagTone }[]
}
export type SuggestBadgeTone = "muted" | "success" | "danger"
@@ -27,6 +29,29 @@ const badgeToneClass: Record<SuggestBadgeTone, string> = {
danger: "bg-destructive/10 text-destructive",
}
export type SuggestTagTone = "serial" | "batch"
const tagToneClass: Record<SuggestTagTone, string> = {
serial: "border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300",
batch: "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
}
function OptionLabel({ option }: { option: SuggestOption }) {
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{option.label}</span>
{option.tags?.map((tag) => (
<span
key={tag.label}
className={cn("shrink-0 rounded-md border px-1.5 py-px text-[11px] font-semibold uppercase tracking-wide", tagToneClass[tag.tone])}
>
{tag.label}
</span>
))}
</span>
)
}
interface SuggestInputProps {
options: SuggestOption[]
/** Called with the picked option's value; the box then clears for the next entry. */
@@ -259,11 +284,11 @@ export function SuggestInput({
>
{option.detail ? (
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<OptionLabel option={option} />
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
</span>
) : (
<span className="truncate">{option.label}</span>
<OptionLabel option={option} />
)}
{(option.badge || option.hint) && (
<span className="flex shrink-0 items-center gap-2">