serial numbers
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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)}`)
|
||||
},
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user