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 })}>
|
||||
|
||||
Reference in New Issue
Block a user