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
@@ -18,6 +18,7 @@ import { CreatePurchaseReturnLineInput } from "@/types/procurement"
import { Grn, GrnLine } from "@/types/grn"
import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -39,6 +40,8 @@ const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background
interface LineState {
selected: boolean
qty: string
/** Serial line: the received serials going back to the vendor (qty follows the count). */
serialIds?: number[]
}
function NewPurchaseReturnContent() {
@@ -62,6 +65,7 @@ function NewPurchaseReturnContent() {
const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const { serialNumbersEnabled } = useTrackingSettings()
useEffect(() => {
Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
@@ -121,6 +125,33 @@ function NewPurchaseReturnContent() {
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } }))
}
function toggleSerial(grnLineId: number, serialId: number) {
setLineState((prev) => {
const current = prev[grnLineId]?.serialIds ?? []
const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId]
return { ...prev, [grnLineId]: { selected: next.length > 0, qty: next.length > 0 ? String(next.length) : "", serialIds: next } }
})
}
/** A serial line lists its received serials to tick — the server refuses any no longer in stock. */
function renderSerials(line: GrnLine) {
// Serial numbers switched off in Product Configuration: the line goes back as plain stock.
if (serialNumbersEnabled === false) return null
const serials = (line.serials ?? []).filter((s) => s.serialId !== null)
if (serials.length === 0) return null
const picked = lineState[line.grnLineId]?.serialIds ?? []
return (
<div className="mt-2 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={picked.includes(s.serialId!)} onCheckedChange={() => toggleSerial(line.grnLineId, s.serialId!)} />
<span className="font-mono">{s.serialNo}</span>
</label>
))}
</div>
)
}
async function handleSubmit() {
setHeaderError(null)
setSubmitError(null)
@@ -143,6 +174,8 @@ function NewPurchaseReturnContent() {
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 (serialNumbersEnabled !== false && (line.serials ?? []).some((s) => s.serialId !== null) && (lineState[line.grnLineId].serialIds?.length ?? 0) !== Number(lineState[line.grnLineId].qty))
errors.qty = "Tick one serial per unit going back"
if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors
}
setLineErrors(nextErrors)
@@ -155,6 +188,7 @@ function NewPurchaseReturnContent() {
grnLineId: l.grnLineId,
itemId: l.itemId,
qty: Number(lineState[l.grnLineId].qty),
serialIds: lineState[l.grnLineId].serialIds?.length ? lineState[l.grnLineId].serialIds : null,
}))
setSubmitting(true)
@@ -249,6 +283,7 @@ function NewPurchaseReturnContent() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</span>
</label>
{renderSerials(line)}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
@@ -309,7 +344,10 @@ function NewPurchaseReturnContent() {
aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`}
/>
</TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{renderSerials(line)}
</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} />
@@ -11,6 +11,7 @@ import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
import { invalidateTrackingSettings } from "@/hooks/use-tracking-settings"
/**
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
@@ -61,6 +62,8 @@ export default function ProductSettingsPage() {
)
setConfig(res.data)
setEtag(res.etag)
// Screens read the serial switch through a cached hook — make the next read fresh.
invalidateTrackingSettings()
toast.success("Configuration saved", message)
return true
} catch (err) {
@@ -152,7 +155,7 @@ export default function ProductSettingsPage() {
<ToggleRow
label="Serial numbers"
description="Allows items to be created with Serial tracking."
description="On: serial-tracked items need one serial per unit on GRNs, sales, returns, transfers, adjustments and counts, and warranties follow each serial. Off: no serials are asked for or accepted anywhere; serial items are handled as plain stock (oldest first) and the Serials page is hidden. Existing serial history is kept."
checked={config.serialNumbersEnabled}
busy={saving === "serialNumbersEnabled"}
onChange={(v) => toggle("serialNumbersEnabled", v)}
@@ -13,7 +13,7 @@ import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { ConfirmGrnResponse, Grn, GrnLine } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
@@ -27,6 +27,34 @@ import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badg
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
function LineTrackingInfo({ line }: { line: GrnLine }) {
const serials = line.serials ?? []
if (!line.batchNo && serials.length === 0) return null
return (
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
{line.batchNo && (
<p>
Batch <span className="font-medium text-foreground">{line.batchNo}</span>
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
</p>
)}
{serials.length > 0 && (
<details>
<summary className="cursor-pointer select-none">
{serials.length} serial number{serials.length === 1 ? "" : "s"}
</summary>
<ul className="mt-1 grid max-h-48 grid-cols-2 gap-x-4 overflow-y-auto font-mono">
{serials.map((s) => (
<li key={s.serialNo}>{s.serialNo}</li>
))}
</ul>
</details>
)}
</div>
)
}
export default function GrnDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
@@ -235,6 +263,7 @@ export default function GrnDetailPage() {
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} />
</div>
<HoldStatusBadge status={line.holdStatus} />
</div>
@@ -357,6 +386,7 @@ export default function GrnDetailPage() {
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
<LineTrackingInfo line={line} />
</TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2, Wand2 } from "lucide-react"
import { ScanInput } from "@/components/scanning/ScanInput"
import { ApiError } from "@/lib/api-client"
@@ -15,6 +15,8 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { grnConfigApi } from "@/lib/api/grn-config"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { trackingApi } from "@/lib/api/tracking"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
@@ -40,12 +42,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
type Mode = "po" | "direct"
type LineTab = "lines" | "warranty"
/** Whole units a line's qty represents — one warranty number is captured per unit. */
/** Whole units a line's qty represents — one warranty/serial number is captured per unit. */
function unitCount(qty: string): number {
const n = Math.floor(Number(qty))
return Number.isFinite(n) && n > 0 ? n : 0
}
/**
* Items that need one number per received unit: serial-tracked items (their serial numbers —
* which double as the warranty identity, docs/ITEM_TRACKING_PLAN.md D1) and other warranty items.
*/
function capturesUnitNumbers(item: ItemListItem | null | undefined): boolean {
return item?.trackingMode === "Serial" || item?.warranty === "Warranty"
}
/** "serial number" for a serial-tracked item, else "warranty number". */
function unitNumberLabel(item: ItemListItem | null | undefined): string {
return item?.trackingMode === "Serial" ? "serial number" : "warranty number"
}
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
function WarrantyHintBubble() {
const [visible, setVisible] = useState(true)
@@ -83,10 +98,15 @@ interface DraftLine {
discountPct: string
vatPct: string
holdStatus: HoldStatus
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
/**
* One entry per received unit, index-aligned; only meaningful when the item captures unit
* numbers — sent as `serialNumbers` for a serial-tracked item, else as `warrantyNumbers`.
*/
warrantyNumbers: string[]
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo, expiryDate }` on submit. */
batchNo: string
/** yyyy-mm-dd, optional — drives FEFO picking and expiry blocking. */
batchExpiry: string
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
@@ -155,10 +175,12 @@ function buildWarrantyRange(from: string, to: string): string[] | null {
* with a generated number — existing (manually edited) values are left untouched.
*/
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
if (item?.warranty !== "Warranty") return l.warrantyNumbers
if (!capturesUnitNumbers(item)) return l.warrantyNumbers
const units = unitCount(l.qty)
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
// Serial numbers are the manufacturer's — never invent them; leave the slots for scanning.
if (item?.trackingMode === "Serial") return next
for (let i = 0; i < units; i++) {
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
}
@@ -179,12 +201,14 @@ function emptyLine(): DraftLine {
holdStatus: "Available",
warrantyNumbers: [],
batchNo: "",
batchExpiry: "",
}
}
export default function NewGrnPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [mode, setMode] = useState<Mode>("po")
@@ -339,6 +363,7 @@ export default function NewGrnPage() {
holdStatus: "Available",
warrantyNumbers: [],
batchNo: "",
batchExpiry: "",
}
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
})
@@ -440,8 +465,16 @@ export default function NewGrnPage() {
setLines((prev) => prev.filter((l) => l.key !== key))
}
/**
* The item as this GRN must treat it: with serial numbers switched off in Product
* Configuration a Serial item is received as untracked stock (a warranty item then takes
* warranty numbers again), so every serial rule below follows from this one place.
*/
function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null
const item = items?.find((i) => i.itemId === itemId) ?? null
return item && item.trackingMode === "Serial" && serialNumbersEnabled === false
? { ...item, trackingMode: "None" as const }
: item
}
const itemOptions = useMemo<SuggestOption[]>(
@@ -530,11 +563,68 @@ export default function NewGrnPage() {
// From/To are left exactly as typed — not cleared — so a typo is easy to fix in place.
}
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
// the "Warranty numbers" tab needs to capture, one input per unit.
// Lines whose item is serial- or warranty-tracked and carry at least one unit — these are
// the rows the "Serial / warranty numbers" tab needs to capture, one input per unit.
const warrantyLines = lines
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0)
.filter((w) => capturesUnitNumbers(w.item) && w.units > 0)
// Bulk paste for a line's serial/warranty numbers — one per line (or comma-separated), filling
// units from the first; extra pasted values beyond the unit count are reported, not dropped silently.
const [pasteInputs, setPasteInputs] = useState<Record<string, string>>({})
function applyPastedNumbers(lineKey: string, units: number) {
const values = (pasteInputs[lineKey] ?? "").split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0)
if (values.length === 0) return
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
values.slice(0, units).forEach((v, i) => (next[i] = v))
return { ...l, warrantyNumbers: next }
})
)
setPasteInputs((prev) => ({ ...prev, [lineKey]: "" }))
if (values.length > units)
toast.error("Too many numbers", `${values.length} pasted for ${units} unit(s) — the extra ${values.length - units} were not used.`)
}
// Auto-generate serial numbers: the server continues the highest number already used under the
// prefix (received + draft GRNs); numbers already typed on this form are skipped too. Only empty
// unit slots are filled — scanned/typed serials are never overwritten.
const [genPrefixes, setGenPrefixes] = useState<Record<string, string>>({})
const [generating, setGenerating] = useState<string | null>(null)
async function autoGenerateSerials(lineKey: string, itemId: number, units: number, prefix: string) {
const line = lines.find((l) => l.key === lineKey)
if (!line) return
const blanks = Array.from({ length: units }, (_, i) => i).filter((i) => !(line.warrantyNumbers[i] ?? "").trim())
if (blanks.length === 0) {
toast.error("Nothing to generate", "Every unit already has a serial number — clear some first.")
return
}
const inForm = new Set(
lines.filter((l) => l.itemId === itemId).flatMap((l) => l.warrantyNumbers.map((s) => s.trim().toLowerCase())).filter((s) => s)
)
setGenerating(lineKey)
try {
const fetched = await trackingApi.nextSerialNumbers(itemId, prefix, Math.min(1000, blanks.length + inForm.size))
const fresh = fetched.filter((n) => !inForm.has(n.toLowerCase())).slice(0, blanks.length)
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
blanks.forEach((slot, i) => { if (fresh[i]) next[slot] = fresh[i] })
return { ...l, warrantyNumbers: next }
})
)
toast.success("Serial numbers generated", `${fresh.length} filled, starting ${fresh[0] ?? "—"}.`)
} catch (err) {
toast.error("Could not generate serial numbers", errorMessage(err))
} finally {
setGenerating(null)
}
}
async function handleSubmit() {
setSubmitError(null)
@@ -576,6 +666,8 @@ export default function NewGrnPage() {
discountPct: allowDiscounts ? line.discountPct : "0",
vatPct: allowVat ? line.vatPct : "0",
warranty: item?.warranty ?? null,
trackingMode: item?.trackingMode ?? null,
batchNo: line.batchNo,
warrantyNumbers: line.warrantyNumbers,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
@@ -593,6 +685,8 @@ export default function NewGrnPage() {
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const item = itemFor(l.itemId)
const unitNumbers = l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
const serial = item?.trackingMode === "Serial"
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
@@ -602,11 +696,13 @@ export default function NewGrnPage() {
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
holdStatus: l.holdStatus,
warrantyNumbers:
item?.warranty === "Warranty"
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
// A serial item's serials are its warranty identity — it sends no warranty numbers.
serialNumbers: serial ? unitNumbers : null,
warrantyNumbers: !serial && item?.warranty === "Warranty" ? unitNumbers : null,
batch:
item?.trackingMode === "Batch" && l.batchNo.trim()
? { batchNo: l.batchNo.trim(), expiryDate: l.batchExpiry || null }
: null,
batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null,
}
})
@@ -861,7 +957,7 @@ export default function NewGrnPage() {
className="rounded-full"
onClick={() => setLineTab("warranty")}
>
Warranty numbers
Serial / warranty numbers
{warrantyLines.length > 0 && (
<Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs">
{warrantyLines.reduce((sum, w) => sum + w.units, 0)}
@@ -951,14 +1047,27 @@ export default function NewGrnPage() {
</div>
{item?.trackingMode === "Batch" && (
<div>
<Label className="text-sm">Batch no</Label>
<Input
value={line.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Batch no</Label>
<Input
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
<div>
<Label className="text-sm">Expiry</Label>
<Input
type="date"
value={line.batchExpiry}
onChange={(e) => updateLine(line.key, { batchExpiry: e.target.value })}
className="h-11 text-sm"
/>
</div>
</div>
)}
@@ -1050,10 +1159,10 @@ export default function NewGrnPage() {
</Select>
</div>
{item?.warranty === "Warranty" && (
{capturesUnitNumbers(item) && (
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
<ShieldCheck className="size-4" />
Add warranty numbers
Add {unitNumberLabel(item)}s
</Button>
)}
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
@@ -1134,12 +1243,24 @@ export default function NewGrnPage() {
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" ? (
<Input
value={line.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
<div className="flex flex-col gap-1.5">
<Input
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
<Input
type="date"
value={line.batchExpiry}
onChange={(e) => updateLine(line.key, { batchExpiry: e.target.value })}
aria-label="Batch expiry date"
title="Expiry date (optional)"
className="h-9 text-xs"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
@@ -1237,7 +1358,7 @@ export default function NewGrnPage() {
</Select>
</TableCell>
<TableCell className="py-3 pr-0 pl-1 align-top">
{item?.warranty === "Warranty" ? (
{capturesUnitNumbers(item) ? (
<div className="relative flex flex-col gap-1">
<Tooltip>
<TooltipTrigger
@@ -1247,13 +1368,13 @@ export default function NewGrnPage() {
variant="outline"
size="icon"
onClick={() => setLineTab("warranty")}
aria-label="Add warranty numbers"
aria-label={`Add ${unitNumberLabel(item)}s`}
/>
}
>
<ShieldCheck className="size-4" />
</TooltipTrigger>
<TooltipContent>Add warranty numbers</TooltipContent>
<TooltipContent>Add {unitNumberLabel(item)}s</TooltipContent>
</Tooltip>
{errors.warrantyNumbers && (
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
@@ -1311,8 +1432,8 @@ export default function NewGrnPage() {
{warrantyLines.length === 0 ? (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
No warranty-tracked items on this GRN yet — add a line for an item marked
“Warranty” to capture its numbers here.
No serial- or warranty-tracked items on this GRN yet — add a line for such an
item to capture one number per unit here.
</p>
) : (
<div className="flex flex-col gap-6">
@@ -1320,11 +1441,65 @@ export default function NewGrnPage() {
const errors = lineErrors[line.key] ?? {}
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
const addError = warrantyAddErrors[line.key]
const serial = item?.trackingMode === "Serial"
const label = unitNumberLabel(item)
const entered = line.warrantyNumbers.filter((s) => s.trim().length > 0).length
return (
<div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
<Badge variant="outline" className="text-xs">{serial ? "Serial" : "Warranty"}</Badge>
{serial && item?.warranty === "Warranty" && (
<Badge variant="outline" className="text-xs">Warranty starts at sale</Badge>
)}
<span className={cn("text-xs font-normal", entered === units ? "text-success" : "text-muted-foreground")}>
{entered} of {units} entered
</span>
</div>
{serial && (
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex min-w-64 flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">Paste or scan serial numbers</Label>
<textarea
value={pasteInputs[line.key] ?? ""}
onChange={(e) => setPasteInputs((prev) => ({ ...prev, [line.key]: e.target.value }))}
placeholder="One per line (or comma-separated)"
rows={3}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => applyPastedNumbers(line.key, units)}>
Fill units
</Button>
</div>
)}
{serial && line.itemId && (
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Prefix</Label>
<Input
value={genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`}
onChange={(e) => setGenPrefixes((prev) => ({ ...prev, [line.key]: e.target.value }))}
placeholder="e.g. PH-"
className="h-9 w-44 text-sm"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={generating === line.key}
onClick={() => autoGenerateSerials(line.key, line.itemId as number, units, genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`)}
>
<Wand2 className="size-4" />
{generating === line.key ? "Generating…" : "Auto-generate"}
</Button>
<span className="text-xs text-muted-foreground">
For units without a manufacturer serial — fills only the empty units with the next
free numbers (e.g. {`${genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`}0001`}).
</span>
</div>
)}
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">From</Label>
@@ -1365,8 +1540,10 @@ export default function NewGrnPage() {
<TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm">
Warranty number
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span>
{serial ? "Serial number" : "Warranty number"}
<span className="ml-1.5 font-normal text-muted-foreground">
{serial ? "(scan or type the unit's serial)" : "(auto-generated — edit if needed)"}
</span>
</TableHead>
</TableRow>
</TableHeader>
@@ -1381,7 +1558,8 @@ export default function NewGrnPage() {
value={line.warrantyNumbers[i] ?? ""}
aria-invalid={!!errors.warrantyNumbers}
onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)}
placeholder="e.g. WTY-000123"
placeholder={serial ? "e.g. SN-48213" : "e.g. WTY-000123"}
aria-label={`${label} for unit ${i + 1}`}
className="h-10 max-w-xs text-sm"
/>
{i === units - 1 && (
@@ -7,6 +7,8 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import {
Dialog,
DialogContent,
@@ -74,6 +76,7 @@ const blankLine = (key: string): Line => ({
export default function NewFreeIssuePage() {
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
@@ -162,8 +165,8 @@ export default function NewFreeIssuePage() {
}
const itemOptions = useMemo(
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
[items]
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
[items, serialNumbersEnabled]
)
function selectItem(key: string, itemId: number) {
@@ -22,10 +22,14 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
@@ -33,7 +37,8 @@ import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHe
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
type Line = CreateSalesInvoiceLineRequest & { key: string }
// batchNo/serials are display state for the tracking picker; batchId + serial ids are what saves.
type Line = CreateSalesInvoiceLineRequest & { key: string; batchNo?: string | null; serials?: SalesLineSerial[] }
const money = new Intl.NumberFormat("en-LK", {
style: "currency",
@@ -70,6 +75,8 @@ function draftSnapshot(customerId: number | null, warehouseId: number | null, in
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: line.unitPrice ?? null,
batchId: line.batchId ?? null,
serialIds: (line.serials ?? []).map((s) => s.serialId),
})),
})
}
@@ -88,6 +95,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [printOpen, setPrintOpen] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null)
@@ -165,6 +173,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
taxPct: line.taxPct,
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId,
batchId: line.batchId ?? null,
batchNo: null,
serials: line.serials ?? [],
}))
setLines(loadedLines)
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
@@ -242,9 +253,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
() =>
items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId)
return { value: String(i.itemId), label: `${i.sku} — ${i.name}`, hint: price !== null ? money.format(price) : undefined }
return {
value: String(i.itemId),
label: `${i.sku} — ${i.name}`,
hint: price !== null ? money.format(price) : undefined,
tags: trackingTags(i.trackingMode, serialNumbersEnabled),
}
}),
[items],
[items, serialNumbersEnabled],
)
function itemLabel(itemId: number) {
@@ -309,6 +325,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
taxPct: Number(line.taxPct),
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
batchId: line.batchId ?? null,
serialIds: line.serials && line.serials.length > 0 ? line.serials.map((s) => s.serialId) : null,
})),
},
etag,
@@ -534,6 +552,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<tr key={line.key} className="border-t border-border align-middle">
<td className="min-w-64 px-4 py-3">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
<LineTrackingPicker
itemId={line.itemId}
warehouseId={line.warehouseId || warehouseId}
trackingMode={items.find((i) => i.itemId === line.itemId)?.trackingMode}
units={Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)}
value={{ batchId: line.batchId ?? null, batchNo: line.batchNo ?? null, serials: line.serials ?? [] }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
</td>
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="w-28 px-4 py-3">
@@ -611,6 +637,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<td className="px-4 py-3">
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
{(line.serials ?? []).length > 0 && (
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")}
</div>
)}
</td>
<td className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
@@ -40,6 +41,9 @@ import {
SalesStockAvailability,
} from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { toast } from "@/components/ui/toast"
// bundleGroupId/bundleName/bundleQty/bundleBaseQty are frontend-only display metadata (never sent
@@ -47,11 +51,10 @@ import { toast } from "@/components/ui/toast"
// component lines added from the same bundle render collapsed into one row, with a single "how
// many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle
// qty from the template so re-scaling on a qty change is exact, not compounding).
// batchId/serialId/batchNo/serialNo are frontend-only display metadata from a barcode/RFID scan
// (see ScanInput below) — the live CreateSalesInvoiceLineRequest DTO has no batch/serial fields
// yet (only CreateSalesSlipLineRequest does), so these are carried for display/traceability on
// this draft line but deliberately NOT sent in submit()'s payload until the invoice line request
// grows the same fields.
// batchId/serials are sent (docs/ITEM_TRACKING_PLAN.md Phase 4): a batch item may pin a batch (else
// posting picks FEFO), a serial item must carry one serial per unit of qty + free qty. They are
// filled by a barcode/RFID scan (ScanInput below) or the per-line LineTrackingPicker.
// batchNo/serialId/serialNo are display metadata from the scan.
type Line = CreateSalesInvoiceLineRequest & {
key: string
bundleGroupId?: string
@@ -62,6 +65,7 @@ type Line = CreateSalesInvoiceLineRequest & {
batchNo?: string | null
serialId?: number | null
serialNo?: string | null
serials?: SalesLineSerial[]
}
type DisplayRow =
@@ -103,6 +107,7 @@ const blankLine = (key: string): Line => ({
batchNo: null,
serialId: null,
serialNo: null,
serials: [],
})
const lkr = new Intl.NumberFormat("en-LK", {
@@ -120,6 +125,7 @@ function NewSalesInvoiceContent() {
const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0)
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
@@ -295,8 +301,7 @@ function NewSalesInvoiceContent() {
// Fast path for a barcode/RFID scan above the lines table: resolves the code to an item (and
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability
// (see the Line type note above re: submit() not yet sending them).
// unit, or appends a new line for it — a scanned serial becomes that line's chosen serial.
// Shared by the "Add item" suggestion box and barcode scans: bumps an existing line for the
// same item+lot by one unit, or appends a new line for it. Returns the affected line's key.
function addItem(
@@ -331,6 +336,7 @@ function NewSalesInvoiceContent() {
batchNo: lot.batchNo ?? null,
serialId: lot.serialId ?? null,
serialNo: lot.serialNo ?? null,
serials: lot.serialId ? [{ serialId: lot.serialId, serialNo: lot.serialNo ?? String(lot.serialId) }] : [],
},
]
})
@@ -517,9 +523,10 @@ function NewSalesInvoiceContent() {
const stocked = i.stockNature === "Stocked"
const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name
// Tracking shows as a highlighted tag (Serial / Batch) instead of detail text.
const tags = trackingTags(i.trackingMode, serialNumbersEnabled)
const detail = [
uom,
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
i.taxRateName ? `Tax: ${i.taxRateName}` : null,
]
@@ -530,11 +537,12 @@ function NewSalesInvoiceContent() {
label: `${i.sku} — ${i.name}`,
hint: price !== null ? lkr.format(price) : undefined,
detail: detail || undefined,
tags,
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
}
}),
[items, uoms, stockByItem],
[items, uoms, stockByItem, serialNumbersEnabled],
)
const bundleTemplateOptions = useMemo(
@@ -617,6 +625,29 @@ function NewSalesInvoiceContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, bundleTemplateIdFromQuery])
/** Units a serial line still needs serials for (0 for any other item). */
function serialsShort(line: Line): number {
const item = items.find((i) => i.itemId === line.itemId)
if (item?.trackingMode !== "Serial" || serialNumbersEnabled === false) return 0
const units = Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)
return Math.max(0, units - (line.serials?.length ?? 0))
}
/** Batch / serial picker under a line's item name (nothing for untracked items). */
function renderTracking(line: Line) {
const item = items.find((i) => i.itemId === line.itemId)
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={line.warehouseId || warehouseId}
trackingMode={item?.trackingMode}
units={Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)}
value={{ batchId: line.batchId ?? null, batchNo: line.batchNo ?? null, serials: line.serials ?? [] }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
)
}
function itemLabel(itemId: number) {
const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
@@ -693,6 +724,11 @@ function NewSalesInvoiceContent() {
const detail = [...new Set(short.map((l) => `${itemLabel(l.itemId)} (${stockShortage(l)} available)`))].join(", ")
return setSubmitError(`Quantity exceeds available stock: ${detail}. Reduce the quantity to continue.`)
}
const missingSerials = lines.filter((line) => serialsShort(line) > 0)
if (missingSerials.length > 0) {
const detail = missingSerials.map((l) => `${itemLabel(l.itemId)} (${serialsShort(l)} more)`).join(", ")
return setSubmitError(`Select a serial number for every unit: ${detail}.`)
}
const payload: CreateSalesInvoiceRequest = {
customerId,
@@ -713,6 +749,8 @@ function NewSalesInvoiceContent() {
taxPct: Number(line.taxPct),
isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null,
batchId: line.batchId ?? null,
serialIds: line.serials && line.serials.length > 0 ? line.serials.map((s) => s.serialId) : null,
})),
}
@@ -954,9 +992,7 @@ function NewSalesInvoiceContent() {
<div className="flex-1">
<Label className="text-sm">Item {idx + 1}</Label>
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
{renderTracking(line)}
</div>
<Button
type="button"
@@ -1150,11 +1186,7 @@ function NewSalesInvoiceContent() {
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">
Lot: {line.batchNo ?? line.serialNo}
</p>
)}
{renderTracking(line)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-sm text-muted-foreground">
@@ -0,0 +1,147 @@
"use client"
import { useEffect, useState } from "react"
import { ShieldCheck } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { WarrantyRegistration } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
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 View = "active" | "expiring" | "expired" | "voided" | "all"
const VIEWS: { value: View; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "expiring", label: "Expiring within 30 days" },
{ value: "expired", label: "Expired" },
{ value: "voided", label: "Voided (returned)" },
{ value: "all", label: "All" },
]
const DOC_LABEL: Record<string, string> = { SI: "Invoice", SSL: "Slip", BND: "Bundle" }
/**
* Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). A warranty starts when the
* sale is posted — never at goods receipt — and is voided if the unit comes back on a return.
*/
export default function WarrantiesPage() {
const [view, setView] = useState<View>("active")
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<WarrantyRegistration> | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const filters =
view === "active" ? { status: "Active" as const, expired: false }
: view === "expiring" ? { expiringWithinDays: 30 }
: view === "expired" ? { status: "Active" as const, expired: true }
: view === "voided" ? { status: "Voided" as const }
: {}
const timer = setTimeout(() => {
trackingApi
.listWarranties({ ...filters, q: q.trim() || undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
}, 250)
return () => { cancelled = true; clearTimeout(timer) }
}, [view, q, page])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Warranties</h1>
<p className="text-base text-muted-foreground">
Customer warranties start on the day the sale is posted. Scan a serial to check its cover.
</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<Input
value={q}
onChange={(e) => { setPage(1); setQ(e.target.value) }}
placeholder="Scan serial, or search SKU / item…"
className="h-11 min-w-64 flex-1 text-base"
/>
<Select<View> value={view} onValueChange={(v) => { setPage(1); if (v) setView(v) }}>
<SelectTrigger className="h-11! w-60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VIEWS.map((v) => (
<SelectItem key={v.value} value={v.value} className="text-sm">{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<ShieldCheck className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No warranties in this view.</p>
</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Item / serial</TableHead>
<TableHead className="px-3">Customer</TableHead>
<TableHead className="px-3">Sold on</TableHead>
<TableHead className="px-3">Cover</TableHead>
<TableHead className="px-3">Ends</TableHead>
<TableHead className="px-3">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((w) => (
<TableRow key={w.warrantyRegistrationId}>
<TableCell className="px-3 py-3">
<div className="font-medium">{w.sku} — {w.itemName}</div>
<div className="font-mono text-xs text-muted-foreground">{w.serialNo ?? `${w.qty} unit${w.qty === 1 ? "" : "s"} (no serial)`}</div>
</TableCell>
<TableCell className="px-3 py-3">{w.customerName}</TableCell>
<TableCell className="px-3 py-3">
{w.startDate}
<div className="text-xs text-muted-foreground">{DOC_LABEL[w.sourceDocType] ?? w.sourceDocType} #{w.sourceDocId}</div>
</TableCell>
<TableCell className="px-3 py-3">{w.periodMonths} months</TableCell>
<TableCell className="px-3 py-3">{w.endDate}</TableCell>
<TableCell className="px-3 py-3">
<Badge
variant="outline"
className={cn(
"text-xs",
w.status !== "Active" ? "text-muted-foreground"
: w.isExpired ? "border-destructive/40 text-destructive"
: w.daysRemaining <= 30 ? "border-warning/50 text-warning"
: "border-success/40 text-success"
)}
>
{w.status !== "Active" ? w.status : w.isExpired ? "Expired" : `${w.daysRemaining} days left`}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
</div>
)
}
@@ -14,6 +14,9 @@ import { cn } from "@/lib/utils"
import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -29,6 +32,14 @@ interface DraftLine {
key: string
itemId: number | null
qtyDelta: string
/** Serial item, negative delta: the units written off. */
serials: SalesLineSerial[]
/** Serial item, positive delta: the serial numbers found, one per line/comma. */
serialNos: string
}
function parseSerialNos(text: string): string[] {
return text.split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0)
}
let keySeq = 0
@@ -38,11 +49,12 @@ function newKey() {
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, qtyDelta: "" }
return { key: newKey(), itemId: null, qtyDelta: "", serials: [], serialNos: "" }
}
export default function NewAdjustmentPage() {
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -90,6 +102,41 @@ export default function NewAdjustmentPage() {
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
}
// Serial numbers switched off in Product Configuration: serial items adjust like plain stock.
const isSerialItem = (itemId: number | null) =>
serialNumbersEnabled !== false && items?.find((i) => i.itemId === itemId)?.trackingMode === "Serial"
/**
* Serial items only: a write-off (negative delta) picks the serials going out; a gain
* (positive delta) takes the serial numbers found, one per line or comma-separated.
*/
function renderSerials(line: DraftLine) {
if (!line.itemId || !isSerialItem(line.itemId)) return null
const delta = Number(line.qtyDelta) || 0
if (delta < 0)
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={warehouseId}
trackingMode="Serial"
units={-delta}
value={{ batchId: null, batchNo: null, serials: line.serials }}
onChange={(v) => updateLine(line.key, { serials: v.serials })}
/>
)
if (delta > 0)
return (
<textarea
value={line.serialNos}
onChange={(e) => updateLine(line.key, { serialNos: e.target.value })}
placeholder={`${delta} serial number(s) found — one per line`}
rows={2}
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
/>
)
return null
}
async function handleSubmit() {
setSubmitError(null)
@@ -110,6 +157,13 @@ export default function NewAdjustmentPage() {
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"
else if (isSerialItem(line.itemId)) {
const delta = Number(line.qtyDelta)
const count = delta < 0 ? line.serials.length : parseSerialNos(line.serialNos).length
if (!Number.isInteger(delta)) nextLineErrors[line.key] = "Serial items change in whole units"
else if (count !== Math.abs(delta))
nextLineErrors[line.key] = `${delta < 0 ? "Select" : "Enter"} ${Math.abs(delta)} serial number(s) — ${count} given`
}
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
@@ -120,6 +174,8 @@ export default function NewAdjustmentPage() {
const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({
itemId: l.itemId as number,
qtyDelta: Number(l.qtyDelta),
serialIds: isSerialItem(l.itemId) && Number(l.qtyDelta) < 0 ? l.serials.map((s) => s.serialId) : null,
serialNos: isSerialItem(l.itemId) && Number(l.qtyDelta) > 0 ? parseSerialNos(l.serialNos) : null,
}))
setSubmitting(true)
@@ -290,6 +346,7 @@ export default function NewAdjustmentPage() {
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
{renderSerials(line)}
</div>
</div>
))}
@@ -333,6 +390,7 @@ export default function NewAdjustmentPage() {
className="h-11 text-base"
/>
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
{renderSerials(line)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
@@ -0,0 +1,147 @@
"use client"
import { useEffect, useState } from "react"
import { trackingApi } from "@/lib/api/tracking"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { Warehouse } from "@/types/master-data"
import { BatchStock } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
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"
const WINDOWS: { label: string; days: number | null }[] = [
{ label: "All batches", days: null },
{ label: "Expired or within 7 days", days: 7 },
{ label: "Expired or within 30 days", days: 30 },
{ label: "Expired or within 90 days", days: 90 },
]
/** Batch stock with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9) — soonest expiry first. */
export default function BatchExpiryPage() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [windowDays, setWindowDays] = useState<number | null>(30)
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<BatchStock> | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
warehousesApi.list().then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
trackingApi
.listBatchStock({ q: q.trim() || undefined, warehouseId: warehouseId ?? undefined, expiringWithinDays: windowDays ?? undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
}, 250)
return () => { cancelled = true; clearTimeout(timer) }
}, [q, warehouseId, windowDays, page])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Batch Expiry</h1>
<p className="text-base text-muted-foreground">
Stock on hand per batch. Expired batches can no longer be issued; sales pick the soonest-expiring batch first.
</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<Input
value={q}
onChange={(e) => { setPage(1); setQ(e.target.value) }}
placeholder="Search batch, SKU or item…"
className="h-11 min-w-64 flex-1 text-base"
/>
<Select<number | null> value={windowDays} onValueChange={(v) => { setPage(1); setWindowDays(v) }}>
<SelectTrigger className="h-11! w-60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((w) => (
<SelectItem key={w.label} value={w.days} className="text-sm">{w.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null> value={warehouseId} onValueChange={(v) => { setPage(1); setWarehouseId(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-sm">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-dashed py-16 text-center text-base text-muted-foreground">No batch stock in this window.</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Item</TableHead>
<TableHead className="px-3">Batch</TableHead>
<TableHead className="px-3">Warehouse</TableHead>
<TableHead className="px-3">Expiry</TableHead>
<TableHead className="px-3 text-right">Qty on hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((b) => (
<TableRow key={`${b.itemId}-${b.warehouseId}-${b.batchId}`}>
<TableCell className="px-3 py-3">
<div className="font-medium">{b.sku}</div>
<div className="text-xs text-muted-foreground">{b.itemName}</div>
</TableCell>
<TableCell className="px-3 py-3 font-mono">{b.batchNo}</TableCell>
<TableCell className="px-3 py-3">{b.warehouseName}</TableCell>
<TableCell className="px-3 py-3">
{b.expiryDate ? (
<span className="flex items-center gap-2">
{b.expiryDate}
<Badge
variant="outline"
className={cn(
"text-xs",
b.isExpired ? "border-destructive/40 text-destructive" : (b.daysToExpiry ?? 999) <= 30 ? "border-warning/50 text-warning" : "text-muted-foreground"
)}
>
{b.isExpired ? "Expired" : `${b.daysToExpiry} days`}
</Badge>
</span>
) : (
<span className="text-muted-foreground">No expiry</span>
)}
</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{b.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
</div>
)
}
@@ -0,0 +1,244 @@
"use client"
import { useEffect, useState } from "react"
import { ScanLine, X } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { Warehouse } from "@/types/master-data"
import { SerialDetail, SerialStatus, SerialSummary } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
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"
const STATUSES: SerialStatus[] = ["InStock", "InTransit", "Sold", "ReturnedByCustomer", "ReturnedToVendor", "Damaged", "Scrapped", "IssuedUntracked"]
const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
InStock: "border-success/40 text-success",
InTransit: "border-info/40 text-info",
Sold: "border-muted-foreground/30 text-muted-foreground",
Damaged: "border-warning/50 text-warning",
Scrapped: "border-destructive/40 text-destructive",
ReturnedToVendor: "border-muted-foreground/30 text-muted-foreground",
}
/** Friendly names for the document-type prefixes a serial movement references. */
const DOC_LABEL: Record<string, string> = {
GRN: "Goods receipt",
SI: "Sales invoice",
SSL: "Sales slip",
BND: "Bundle sale",
SRET: "Sales return",
PRET: "Purchase return",
TRF: "Transfer",
ADJ: "Adjustment",
CNT: "Stock count",
}
function SerialStatusBadge({ status }: { status: SerialStatus }) {
return (
<Badge variant="outline" className={cn("text-xs", STATUS_TONE[status])}>
{status.replace(/([a-z])([A-Z])/g, "$1 $2")}
</Badge>
)
}
/** Serial register (docs/ITEM_TRACKING_PLAN.md Phase 9): every serial, its status and location, and its history. */
export default function SerialsPage() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [q, setQ] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<SerialStatus | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<SerialSummary> | null>(null)
const [error, setError] = useState<string | null>(null)
const [detail, setDetail] = useState<SerialDetail | null>(null)
const { serialNumbersEnabled } = useTrackingSettings()
useEffect(() => {
warehousesApi.list().then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
useEffect(() => {
let cancelled = false
trackingApi
.listSerials({ q: search || undefined, status: status ?? undefined, warehouseId: warehouseId ?? undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [search, status, warehouseId, page])
async function openDetail(serialId: number) {
try {
setDetail(await trackingApi.getSerial(serialId))
} catch (err) {
setError(errorMessage(err))
}
}
const warehouseName = (id: number | null) => (id ? warehouses.find((w) => w.warehouseId === id)?.name ?? `#${id}` : "—")
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Serials</h1>
<p className="text-base text-muted-foreground">Every serial-tracked unit — where it is, what happened to it, and its warranty.</p>
</div>
{serialNumbersEnabled === false && (
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
Serial numbers are switched off in Products → Configuration. This is past history only — no new
serials are recorded, and units issued meanwhile show as “Issued Untracked”.
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<form
className="flex min-w-64 flex-1 gap-2"
onSubmit={(e) => {
e.preventDefault()
setPage(1)
setSearch(q.trim())
}}
>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Scan or search serial, SKU or item…" className="h-11 text-base" />
<Button type="submit" className="h-11">
<ScanLine className="size-4" />
Search
</Button>
</form>
<Select<SerialStatus | null> value={status} onValueChange={(v) => { setPage(1); setStatus(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All statuses</SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s} className="text-sm">{s.replace(/([a-z])([A-Z])/g, "$1 $2")}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null> value={warehouseId} onValueChange={(v) => { setPage(1); setWarehouseId(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-sm">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-dashed py-16 text-center text-base text-muted-foreground">
No serials match. Serials are created when a GRN for a serial-tracked item is confirmed.
</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Serial</TableHead>
<TableHead className="px-3">Item</TableHead>
<TableHead className="px-3">Status</TableHead>
<TableHead className="px-3">Warehouse</TableHead>
<TableHead className="px-3">Received</TableHead>
<TableHead className="px-3">GRN</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((s) => (
<TableRow key={s.serialId} className="cursor-pointer" onClick={() => openDetail(s.serialId)}>
<TableCell className="px-3 py-3 font-mono">{s.serialNo}</TableCell>
<TableCell className="px-3 py-3">
<div className="font-medium">{s.sku}</div>
<div className="text-xs text-muted-foreground">{s.itemName}</div>
</TableCell>
<TableCell className="px-3 py-3"><SerialStatusBadge status={s.status} /></TableCell>
<TableCell className="px-3 py-3">{s.warehouseName ?? "—"}</TableCell>
<TableCell className="px-3 py-3">{s.receivedAt ? new Date(s.receivedAt).toLocaleDateString() : "—"}</TableCell>
<TableCell className="px-3 py-3">{s.grnDocNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
{detail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setDetail(null)}>
<div className="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-background p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-mono text-xl font-bold">{detail.serial.serialNo}</h2>
<p className="text-sm text-muted-foreground">{detail.serial.sku} — {detail.serial.itemName}</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => setDetail(null)} aria-label="Close">
<X className="size-5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div><p className="text-muted-foreground">Status</p><SerialStatusBadge status={detail.serial.status} /></div>
<div><p className="text-muted-foreground">Warehouse</p><p>{detail.serial.warehouseName ?? "—"}</p></div>
<div><p className="text-muted-foreground">GRN</p><p>{detail.serial.grnDocNo ?? "—"}</p></div>
<div><p className="text-muted-foreground">Warranty</p><p>{detail.serial.warrantyPeriodMonths ? `${detail.serial.warrantyPeriodMonths} months` : "None"}</p></div>
</div>
{detail.warranties.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold">Warranty</h3>
{detail.warranties.map((w) => (
<div key={w.warrantyRegistrationId} className="rounded-lg border p-3 text-sm">
<p>
{w.customerName} · {w.startDate} → {w.endDate}{" "}
<Badge variant="outline" className={cn("ml-1 text-xs", w.status === "Active" && !w.isExpired ? "text-success" : "text-muted-foreground")}>
{w.status === "Active" ? (w.isExpired ? "Expired" : `${w.daysRemaining} days left`) : w.status}
</Badge>
</p>
</div>
))}
</div>
)}
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold">History</h3>
{detail.history.length === 0 ? (
<p className="text-sm text-muted-foreground">No recorded movements.</p>
) : (
<ol className="flex flex-col gap-2 border-l pl-4">
{detail.history.map((m) => (
<li key={m.serialMovementId} className="text-sm">
<span className="text-muted-foreground">{new Date(m.createdAt).toLocaleString()}</span>{" — "}
{DOC_LABEL[m.sourceDocType] ?? m.sourceDocType} #{m.sourceDocId}{" → "}
<SerialStatusBadge status={m.status} />
{m.warehouseId && <span className="text-muted-foreground"> at {warehouseName(m.warehouseId)}</span>}
</li>
))}
</ol>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -14,6 +14,9 @@ import { cn } from "@/lib/utils"
import { CreateTransferLineInput } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -29,6 +32,11 @@ interface DraftLine {
itemId: number | null
destBinId: number | null
qty: string
/** Pinned batch (null = server splits FEFO for a batch item). */
batchId: number | null
batchNo: string | null
/** Serial item: the units being moved. */
serials: SalesLineSerial[]
}
let keySeq = 0
@@ -38,11 +46,12 @@ function newKey() {
}
function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, destBinId: null, qty: "" }
return { key: newKey(), itemId: null, destBinId: null, qty: "", batchId: null, batchNo: null, serials: [] }
}
export default function NewTransferPage() {
const router = useRouter()
const { serialNumbersEnabled } = useTrackingSettings()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -114,6 +123,26 @@ export default function NewTransferPage() {
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
}
/** Changing a line's item clears its batch/serial picks — they belonged to the old item. */
function setLineItem(key: string, itemId: number | null) {
updateLine(key, { itemId, batchId: null, batchNo: null, serials: [] })
}
/** Serial / batch picker for a line, drawing on the source warehouse's stock. */
function renderTracking(line: DraftLine) {
if (!line.itemId) return null
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={srcWarehouseId}
trackingMode={items?.find((i) => i.itemId === line.itemId)?.trackingMode}
units={Number(line.qty) || 0}
value={{ batchId: line.batchId, batchNo: line.batchNo, serials: line.serials }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
)
}
async function handleSubmit() {
setSubmitError(null)
@@ -132,8 +161,11 @@ export default function NewTransferPage() {
const nextLineErrors: Record<string, string> = {}
for (const line of lines) {
const serialItem = serialNumbersEnabled !== false && items?.find((i) => i.itemId === line.itemId)?.trackingMode === "Serial"
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"
else if (serialItem && line.serials.length !== Number(line.qty))
nextLineErrors[line.key] = `Select ${Number(line.qty)} serial number(s) — ${line.serials.length} selected`
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
@@ -146,6 +178,8 @@ export default function NewTransferPage() {
srcBinId,
destBinId: l.destBinId,
qty: Number(l.qty),
batchId: l.batchId,
serialIds: l.serials.length > 0 ? l.serials.map((s) => s.serialId) : null,
}))
setSubmitting(true)
@@ -269,7 +303,7 @@ export default function NewTransferPage() {
<div className="flex items-start gap-2">
<div className="flex-1">
<Label className="text-sm">Item</Label>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<Select<number | null> value={line.itemId} onValueChange={(v) => setLineItem(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
@@ -281,6 +315,7 @@ export default function NewTransferPage() {
))}
</SelectContent>
</Select>
{renderTracking(line)}
</div>
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
@@ -337,7 +372,7 @@ export default function NewTransferPage() {
{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 })}>
<Select<number | null> value={line.itemId} onValueChange={(v) => setLineItem(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
@@ -349,6 +384,7 @@ export default function NewTransferPage() {
))}
</SelectContent>
</Select>
{renderTracking(line)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
@@ -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">
@@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import { trackingApi } from "@/lib/api/tracking"
import { TrackingSettings } from "@/types/tracking"
// One request per page load, shared by every component that asks; the Product Configuration
// page calls invalidateTrackingSettings() after a toggle so the next read is fresh.
let cached: Promise<TrackingSettings> | null = null
function load(): Promise<TrackingSettings> {
// A failed read falls back to the server's own default (serial numbers on).
cached ??= trackingApi.settings().catch(() => ({ serialNumbersEnabled: true }))
return cached
}
export function invalidateTrackingSettings() {
cached = null
}
/**
* Product Configuration tracking switches (docs/ITEM_TRACKING_PLAN.md §8). `serialNumbersEnabled`
* is null until loaded — callers treat that like "on", the server default.
*/
export function useTrackingSettings(): { serialNumbersEnabled: boolean | null } {
const [settings, setSettings] = useState<TrackingSettings | null>(null)
useEffect(() => {
let cancelled = false
load().then((s) => { if (!cancelled) setSettings(s) })
return () => { cancelled = true }
}, [])
return { serialNumbersEnabled: settings?.serialNumbersEnabled ?? null }
}
+89
View File
@@ -0,0 +1,89 @@
// Item tracking endpoints — pickers (/tracking), serial register (/serials), warranty register
// (/warranties) and batch stock (/stock/batches). docs/ITEM_TRACKING_PLAN.md.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
AvailableBatch,
AvailableSerial,
BatchStock,
BatchSuggestion,
SerialDetail,
SerialStatus,
SerialSummary,
TrackingSettings,
WarrantyRegistration,
WarrantyStatus,
} from "@/types/tracking"
export interface SerialListParams {
q?: string
itemId?: number
warehouseId?: number
status?: SerialStatus
page?: number
pageSize?: number
}
export interface WarrantyListParams {
q?: string
customerId?: number
itemId?: number
serialNo?: string
status?: WarrantyStatus
expiringWithinDays?: number
expired?: boolean
page?: number
pageSize?: number
}
export interface BatchStockParams {
q?: string
itemId?: number
warehouseId?: number
expiringWithinDays?: number
page?: number
pageSize?: number
}
export const trackingApi = {
settings() {
return apiRequest<TrackingSettings>("/tracking/settings")
},
/** Next free serial numbers for an item under a prefix (auto-generate on GRN). */
nextSerialNumbers(itemId: number, prefix: string, count: number) {
return apiRequest<string[]>(`/tracking/serials/next-numbers${buildQuery({ itemId, prefix, count })}`)
},
availableSerials(itemId: number, warehouseId: number, q?: string) {
return apiRequest<AvailableSerial[]>(`/tracking/serials/available${buildQuery({ itemId, warehouseId, q })}`)
},
availableBatches(itemId: number, warehouseId: number) {
return apiRequest<AvailableBatch[]>(`/tracking/batches/available${buildQuery({ itemId, warehouseId })}`)
},
suggestBatches(itemId: number, warehouseId: number, qty: number) {
return apiRequest<BatchSuggestion[]>(`/tracking/batches/suggest${buildQuery({ itemId, warehouseId, qty })}`)
},
listSerials(params: SerialListParams = {}) {
return apiRequest<PagedResponse<SerialSummary>>(`/serials${buildQuery(params)}`)
},
getSerial(serialId: number) {
return apiRequest<SerialDetail>(`/serials/${serialId}`)
},
lookupSerial(serialNo: string) {
return apiRequest<SerialDetail[]>(`/serials/lookup${buildQuery({ serialNo })}`)
},
listWarranties(params: WarrantyListParams = {}) {
return apiRequest<PagedResponse<WarrantyRegistration>>(`/warranties${buildQuery(params)}`)
},
listBatchStock(params: BatchStockParams = {}) {
return apiRequest<PagedResponse<BatchStock>>(`/stock/batches${buildQuery(params)}`)
},
}
+15
View File
@@ -0,0 +1,15 @@
import type { SuggestTagTone } from "@/components/ui/suggest-input"
import type { TrackingMode } from "@/types/master-data"
/**
* Highlighted tracking tag for an item in a dropdown: "Serial" or "Batch". A Serial item gets no
* tag while serial numbers are switched off in Product Configuration — it is plain stock then.
*/
export function trackingTags(
trackingMode: TrackingMode,
serialNumbersEnabled: boolean | null,
): { label: string; tone: SuggestTagTone }[] {
if (trackingMode === "Serial" && serialNumbersEnabled !== false) return [{ label: "Serial", tone: "serial" }]
if (trackingMode === "Batch") return [{ label: "Batch", tone: "batch" }]
return []
}
+14 -5
View File
@@ -3,7 +3,7 @@
// (over-receipt tolerance, referential existence, concurrency) are never
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
import { z } from "zod"
import { Warranty } from "@/types/master-data"
import { TrackingMode, Warranty } from "@/types/master-data"
export const grnHeaderSchema = z.object({
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
@@ -18,6 +18,9 @@ export function validateLine(input: {
discountPct: string
vatPct: string
warranty: Warranty | null
trackingMode: TrackingMode | null
batchNo: string
/** Per-unit numbers: serial numbers for a Serial item, else warranty numbers for a Warranty item. */
warrantyNumbers: string[]
}): Record<string, string> {
const errors: Record<string, string> = {}
@@ -38,14 +41,20 @@ export function validateLine(input: {
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0–100%"
if (input.warranty === "Warranty") {
if (input.trackingMode === "Batch" && !input.batchNo.trim()) errors.batchNo = "Enter the batch number"
const serial = input.trackingMode === "Serial"
if (serial && !Number.isNaN(qty) && !Number.isInteger(qty)) errors.qty = "Serial-tracked items are received in whole units"
if (serial || input.warranty === "Warranty") {
const label = serial ? "serial number" : "warranty number"
const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
if (numbers.length === 0) {
errors.warrantyNumbers = "Enter one warranty number per unit"
errors.warrantyNumbers = `Enter one ${label} per unit`
} else if (!Number.isNaN(qty) && numbers.length !== qty) {
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}`
errors.warrantyNumbers = `Enter exactly ${qty || 0} ${label}(s) — got ${numbers.length}`
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
errors.warrantyNumbers = "Warranty numbers must be unique"
errors.warrantyNumbers = `${serial ? "Serial" : "Warranty"} numbers must be unique`
}
}
+18 -6
View File
@@ -1,10 +1,7 @@
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
//
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for
// Serial-tracked items, but the backend's CreateGrnLineInput has no such field (only
// `batch`). There is therefore deliberately no `serialNumbers` here: typing it would
// invite the UI to send data the server silently drops. Tracked in Frontend/PROGRESS.md.
// Serial numbers (FR-GRN-04) are captured per unit for Serial-tracked items — see
// docs/ITEM_TRACKING_PLAN.md Phase 2.
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
export type HoldStatus = "OnHold" | "Available" | "Rejected"
@@ -51,9 +48,15 @@ export interface CreateGrnLineInput {
batch?: BatchInput | null
/**
* Required, one per received unit (count must equal `qty`), when the item is
* warranty-tracked (`Item.warranty === "Warranty"`). Ignored otherwise.
* warranty-tracked (`Item.warranty === "Warranty"`) and NOT serial-tracked — a serial
* item's serial number is its warranty identity. Ignored otherwise.
*/
warrantyNumbers?: string[] | null
/**
* Required, one per received unit (qty must be whole), when the item is serial-tracked
* (`Item.trackingMode === "Serial"`). Rejected for any other item.
*/
serialNumbers?: string[] | null
}
export interface CreateGrnRequest {
@@ -90,6 +93,15 @@ export interface GrnLine {
holdStatus: HoldStatus
batchId: number | null
warrantyNumbers: GrnLineWarrantyNumber[]
batchNo: string | null
batchExpiryDate: string | null
/** Serials captured on the line; `serialId` is set once the GRN is confirmed. */
serials: GrnLineSerial[]
}
export interface GrnLineSerial {
serialNo: string
serialId: number | null
}
export interface Grn {
+2
View File
@@ -265,6 +265,8 @@ export interface CreatePurchaseReturnLineInput {
grnLineId?: number | null
itemId: number
qty: number
/** Serial-tracked item: the serials sent back (become ReturnedToVendor). */
serialIds?: number[] | null
}
export interface CreatePurchaseReturnRequest {
+37
View File
@@ -1,3 +1,4 @@
import { SalesLineSerial } from "@/types/tracking"
import { EntityStatus } from "@/types/common"
export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit"
@@ -24,6 +25,8 @@ export interface SalesInvoiceLine {
taxAmount: number
isFreeIssue: boolean
parentLineId: number | null
batchId: number | null
serials: SalesLineSerial[]
}
export interface SalesInvoiceTotals {
@@ -197,6 +200,10 @@ export interface CreateSalesInvoiceLineRequest {
taxPct: number
isFreeIssue: boolean
parentLineId?: number | null
/** Pins one batch; omit to let posting pick FEFO. */
batchId?: number | null
/** Serial-tracked item: one in-stock serial per unit of qty + freeQty. */
serialIds?: number[] | null
}
export interface CreateSalesInvoiceRequest {
@@ -230,6 +237,9 @@ export interface SalesSlipLine {
taxAmount: number
isFreeIssue: boolean
parentLineId: number | null
batchId: number | null
serialId: number | null
serials: SalesLineSerial[]
}
export interface SalesSlipTotals {
@@ -283,6 +293,12 @@ export interface CreateSalesSlipLineRequest {
taxPct: number
isFreeIssue: boolean
parentLineId?: number | null
/** Pins one batch; omit to let posting pick FEFO. */
batchId?: number | null
/** Legacy single-serial pin; prefer `serialIds`. */
serialId?: number | null
/** Serial-tracked item: one in-stock serial per unit of qty + freeQty. */
serialIds?: number[] | null
}
export interface CreateSalesSlipRequest {
@@ -409,6 +425,9 @@ export interface SalesReturnLine {
salesInvoiceLineId: number | null
itemId: number
qty: number
/** Batch restocked into — the batch the goods were sold from. */
batchId: number | null
serials: SalesLineSerial[]
}
export interface SalesReturn {
@@ -444,12 +463,28 @@ export interface SalesReturnSummary {
export interface SalesInvoiceLineRemaining {
salesInvoiceLineId: number
remainingQty: number
/** What the posted line actually issued (batch/serial), net of returns — what a return can take back. */
allocations: SalesLineAllocation[]
}
export interface SalesLineAllocation {
batchId: number | null
batchNo: string | null
expiryDate: string | null
serialId: number | null
serialNo: string | null
qty: number
qtyReturned: number
}
export interface CreateSalesReturnLineInput {
salesInvoiceLineId?: number | null
itemId: number
qty: number
/** Batch to restock into; optional when the line issued a single batch. */
batchId?: number | null
/** Serial-tracked item: the returned serials, one per unit. */
serialIds?: number[] | null
}
export interface CreateSalesReturnRequest {
@@ -457,6 +492,8 @@ export interface CreateSalesReturnRequest {
warehouseId: number
/** Mandatory; omitting it returns 400 REASON_CODE_REQUIRED. */
reasonCodeId: number
/** Returned serials become Damaged instead of InStock. Omit to derive from the reason (DEF/QREJ ⇒ damaged). */
markDamaged?: boolean | null
lines: CreateSalesReturnLineInput[]
}
+12
View File
@@ -76,6 +76,8 @@ export interface TransferLine {
qty: number
/** Accrues on partial receive; qty - qtyReceived is what is still in transit. */
qtyReceived: number
/** Serial-tracked item: the units moved; `received` once each has arrived. */
serials: { serialId: number; serialNo: string; received: boolean }[]
}
export interface StockTransfer {
@@ -106,8 +108,11 @@ export interface CreateTransferLineInput {
itemId: number
srcBinId?: number | null
destBinId?: number | null
/** Omit for a batch-tracked item to have the server split the line FEFO across batches. */
batchId?: number | null
qty: number
/** Serial-tracked item: one in-stock serial (at the source) per unit. */
serialIds?: number[] | null
}
export interface CreateTransferRequest {
@@ -136,6 +141,8 @@ export interface DispatchTransferResponse {
export interface ReceiveTransferLineInput {
transferLineId: number
qty: number
/** Serial line: which in-transit serials arrived; omit when receiving all outstanding. */
serialIds?: number[] | null
}
export interface ReceiveTransferRequest {
@@ -169,6 +176,7 @@ export interface AdjustmentLine {
batchId: number | null
/** Signed: negative FIFO-consumes, positive creates a layer. */
qtyDelta: number
serialId: number | null
}
export interface StockAdjustment {
@@ -202,6 +210,10 @@ export interface CreateAdjustmentLineInput {
binId?: number | null
batchId?: number | null
qtyDelta: number
/** Negative delta, serial item: the serials written off (become Scrapped). */
serialIds?: number[] | null
/** Positive delta, serial item: the serial numbers found (created, or brought back InStock). */
serialNos?: string[] | null
}
export interface CreateAdjustmentRequest {
+120
View File
@@ -0,0 +1,120 @@
// Item tracking — serials, batches, warranty (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9).
// Mirrors Backend Dtos/Stock/TrackingDtos.cs.
export type SerialStatus =
| "InStock"
| "Reserved"
| "InTransit"
| "Sold"
| "ReturnedByCustomer"
| "ReturnedToVendor"
| "Damaged"
| "Scrapped"
/** Left stock without being named while serial numbers were switched off. */
| "IssuedUntracked"
export type WarrantyStatus = "Active" | "Voided" | "Claimed"
/** Product Configuration switches every screen can read (GET /tracking/settings). */
export interface TrackingSettings {
serialNumbersEnabled: boolean
}
/** A serial chosen on (or sold by) a sales line. */
export interface SalesLineSerial {
serialId: number
serialNo: string
}
export interface AvailableSerial {
serialId: number
serialNo: string
batchId: number | null
batchNo: string | null
status: SerialStatus
}
export interface AvailableBatch {
batchId: number | null
batchNo: string | null
/** `YYYY-MM-DD` */
expiryDate: string | null
qty: number
daysToExpiry: number | null
}
export interface BatchSuggestion {
batchId: number | null
batchNo: string | null
expiryDate: string | null
qty: number
}
export interface SerialSummary {
serialId: number
itemId: number
sku: string
itemName: string
serialNo: string
status: SerialStatus
warehouseId: number | null
warehouseName: string | null
binId: number | null
receivedAt: string | null
grnId: number | null
grnDocNo: string | null
warrantyPeriodMonths: number | null
}
export interface SerialMovement {
serialMovementId: number
/** Document-type prefix (GRN, SI, SSL, SRET, PRET, TRF, ADJ, CNT). */
sourceDocType: string
sourceDocId: number
status: SerialStatus
fromWarehouseId: number | null
warehouseId: number | null
userId: number
createdAt: string
}
export interface WarrantyRegistration {
warrantyRegistrationId: number
itemId: number
sku: string
itemName: string
serialId: number | null
serialNo: string | null
customerId: number
customerName: string
sourceDocType: string
sourceDocId: number
sourceLineId: number
qty: number
startDate: string
periodMonths: number
endDate: string
status: WarrantyStatus
daysRemaining: number
isExpired: boolean
}
export interface SerialDetail {
serial: SerialSummary
history: SerialMovement[]
warranties: WarrantyRegistration[]
}
export interface BatchStock {
itemId: number
sku: string
itemName: string
warehouseId: number
warehouseName: string
batchId: number | null
batchNo: string | null
expiryDate: string | null
qty: number
daysToExpiry: number | null
isExpired: boolean
}