serial numbers
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user