feat: add warranty tracking for items and GRN lines

- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
This commit is contained in:
2026-08-13 12:01:11 +05:30
parent 18475fdc9f
commit 179d5b0803
17 changed files with 521 additions and 111 deletions
@@ -12,7 +12,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TrackingMode } from "@/types/master-data"
import { Item, MeasureUnit, StockNature, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
@@ -54,6 +54,9 @@ export default function ItemDetailPage() {
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
// Not editable here — carried through unchanged so a save doesn't silently reset it.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("")
// Raw string: an empty box means "no content size", which is not the same as 0.
const [contentQty, setContentQty] = useState("")
@@ -80,6 +83,8 @@ export default function ItemDetailPage() {
setDefaultVendorId(data.defaultVendorId)
setStockNature(data.stockNature)
setTrackingMode(data.trackingMode)
setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "")
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
setContentUnit(data.contentUnit)
@@ -123,7 +128,7 @@ export default function ItemDetailPage() {
item.itemId,
{
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null,
contentQty: contentQty.trim() ? Number(contentQty) : null,
contentUnit: contentQty.trim() ? contentUnit : null,
@@ -20,7 +20,18 @@ import {
validateVariantPrices,
} from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Brand, Category, ItemType, MeasureUnit, ProductConfig, StockNature, SubCategory } from "@/types/master-data"
import {
Brand,
Category,
ItemType,
MeasureUnit,
ProductConfig,
StockNature,
SubCategory,
WARRANTY_PERIOD_MONTHS_OPTIONS,
Warranty,
WarrantyPeriodMonths,
} from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
@@ -125,6 +136,10 @@ export default function NewItemPage() {
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
const [priceErrors, setPriceErrors] = useState<Record<string, string>>({})
// Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
@@ -340,6 +355,9 @@ export default function NewItemPage() {
if (measurableCheckedCount > 1) {
nextErrors.measurable = "Only one measurement dimension can be used at a time."
}
if (warranty === "Warranty" && warrantyPeriodMonths === null) {
nextErrors.warrantyPeriodMonths = "Select a warranty period"
}
// Should never fire — addValue is the real gate — so it catches stale state only.
const contentSweep = validateVariantContent(
variants.map((v) => v.key),
@@ -381,6 +399,8 @@ export default function NewItemPage() {
baseUomId,
stockNature,
trackingMode: "None",
warranty,
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
// Each variant carries its OWN size when a measurement dimension supplied one;
// otherwise the shared form-level pair, which is correct when the varying dimension
@@ -672,6 +692,64 @@ export default function NewItemPage() {
)}
</div>
{/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */}
<div className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty.
</p>
</div>
<div className="inline-flex w-fit rounded-lg border p-1">
<button
type="button"
onClick={() => {
setWarranty("NonWarranty")
setWarrantyPeriodMonths(null)
}}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)}
</div>
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
item-type reference), so this section IS the enforcement. */}
{config?.itemTypesEnabled && (
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -176,7 +177,20 @@ export default function GrnDetailPage() {
const item = itemFor(line.itemId)
return (
<TableRow key={line.grnLineId}>
<TableCell className="px-3 py-3.5">{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-2">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && <Badge variant="outline" className="text-xs">Warranty</Badge>}
</div>
{line.warrantyNumbers.length > 0 && (
<p
className="mt-1 text-xs text-muted-foreground"
title={line.warrantyNumbers.map((w) => `${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")}
>
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p>
)}
</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>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -13,12 +13,13 @@ import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { validateLine, splitSerials, grnHeaderSchema } from "@/lib/validations/grn"
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
import { cn } from "@/lib/utils"
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -27,8 +28,41 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
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. */
function unitCount(qty: string): number {
const n = Math.floor(Number(qty))
return Number.isFinite(n) && n > 0 ? n : 0
}
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
function WarrantyHintBubble() {
const [visible, setVisible] = useState(true)
useEffect(() => {
const timer = setTimeout(() => setVisible(false), 3000)
return () => clearTimeout(timer)
}, [])
return (
<div
role="status"
className={cn(
"absolute bottom-full right-0 z-10 mb-2 flex w-max max-w-xs items-center gap-2 rounded-xl border border-info/40 bg-white p-3 shadow-xl transition-opacity duration-700",
visible ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-info/15 text-info">
<ShieldCheck className="size-4" />
</span>
<span className="min-w-0 text-sm font-semibold text-info">Add warranty numbers for this item</span>
</div>
)
}
interface DraftLine {
key: string
@@ -42,9 +76,8 @@ interface DraftLine {
discountPct: string
vatPct: string
holdStatus: HoldStatus
batchNo: string
expiryDate: string
serialNumbersText: string
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
warrantyNumbers: string[]
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
@@ -77,9 +110,7 @@ function emptyLine(): DraftLine {
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
warrantyNumbers: [],
}
}
@@ -101,6 +132,7 @@ export default function NewGrnPage() {
const [poId, setPoId] = useState<number | null>(null)
const [poLoading, setPoLoading] = useState(false)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [lineTab, setLineTab] = useState<LineTab>("lines")
const [headerError, setHeaderError] = useState<string | null>(null)
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
@@ -174,9 +206,7 @@ export default function NewGrnPage() {
discountPct: "0",
vatPct: "0",
holdStatus: "Available",
batchNo: "",
expiryDate: "",
serialNumbersText: "",
warrantyNumbers: [],
})
)
)
@@ -214,6 +244,24 @@ export default function NewGrnPage() {
return items?.find((i) => i.itemId === itemId) ?? null
}
function setWarrantyNumberAt(lineKey: string, index: number, value: string) {
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = [...l.warrantyNumbers]
while (next.length <= index) next.push("")
next[index] = value
return { ...l, warrantyNumbers: next }
})
)
}
// 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.
const warrantyLines = lines
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0)
async function handleSubmit() {
setSubmitError(null)
setHeaderError(null)
@@ -242,26 +290,31 @@ export default function NewGrnPage() {
const nextLineErrors: Record<string, Record<string, string>> = {}
for (const line of lines) {
const item = itemFor(line.itemId)
const errors = validateLine({
itemId: line.itemId,
qty: line.qty,
unitCost: line.unitCost,
discountPct: line.discountPct,
vatPct: line.vatPct,
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
batchNo: line.batchNo,
serialNumbersText: line.serialNumbersText,
warranty: item?.warranty ?? null,
warrantyNumbers: line.warrantyNumbers,
})
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
}
setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) {
// Jump to whichever tab actually shows the offending field(s).
const onlyWarrantyErrors = Object.values(nextLineErrors).every(
(errs) => Object.keys(errs).every((k) => k === "warrantyNumbers")
)
setLineTab(onlyWarrantyErrors ? "warranty" : "lines")
setSubmitError("Fix the highlighted lines before submitting.")
return
}
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
const item = itemFor(l.itemId)
return {
poLineId: l.poLineId,
itemId: l.itemId as number,
@@ -271,8 +324,10 @@ export default function NewGrnPage() {
discountPct: Number(l.discountPct) || 0,
vatPct: Number(l.vatPct) || 0,
holdStatus: l.holdStatus,
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
warrantyNumbers:
item?.warranty === "Warranty"
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
: null,
}
})
@@ -432,23 +487,49 @@ export default function NewGrnPage() {
</div>
</div>
<div className="flex w-fit gap-2 rounded-full border border-input bg-muted/40 p-1">
<Button
type="button"
size="sm"
variant={lineTab === "lines" ? "default" : "ghost"}
className="rounded-full"
onClick={() => setLineTab("lines")}
>
Lines
</Button>
<Button
type="button"
size="sm"
variant={lineTab === "warranty" ? "default" : "ghost"}
className="rounded-full"
onClick={() => setLineTab("warranty")}
>
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)}
</Badge>
)}
</Button>
</div>
{poLoading && <Skeleton className="h-24 w-full" />}
{!poLoading && lines.length > 0 && (
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-6 px-1 text-sm">Action</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
</TableHeader>
@@ -460,40 +541,47 @@ export default function NewGrnPage() {
<TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top">
{line.poLineId ? (
<div className="flex h-11 items-center text-base">
<div className="flex h-11 items-center gap-2 text-sm">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="text-xs">Warranty</Badge>
)}
</div>
) : (
<>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
<SelectTrigger className="h-11! w-full text-sm" aria-invalid={!!errors.itemId}>
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.sku} {i.name}
{i.warranty === "Warranty" ? " (Warranty)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{item?.warranty === "Warranty" && (
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
)}
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-base text-muted-foreground">
<div className="flex h-11 items-center text-sm text-muted-foreground">
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
</div>
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
<SelectTrigger className="h-11! w-full text-base">
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{bins.map((b) => (
<SelectItem key={b.binId} value={b.binId} className="text-base">
<SelectItem key={b.binId} value={b.binId} className="text-sm">
{b.code}
</SelectItem>
))}
@@ -508,7 +596,7 @@ export default function NewGrnPage() {
value={line.qty}
aria-invalid={!!errors.qty}
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
</TableCell>
@@ -520,7 +608,7 @@ export default function NewGrnPage() {
value={line.unitCost}
aria-invalid={!!errors.unitCost}
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
@@ -538,7 +626,7 @@ export default function NewGrnPage() {
value={line.discountPct}
aria-invalid={!!errors.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
</TableCell>
@@ -551,7 +639,7 @@ export default function NewGrnPage() {
value={line.vatPct}
aria-invalid={!!errors.vatPct}
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
className="h-11 text-base"
className="h-11 text-sm"
/>
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
</TableCell>
@@ -573,51 +661,44 @@ export default function NewGrnPage() {
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
<SelectTrigger className="h-11! w-full text-base">
<SelectTrigger className="h-11! w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Available" className="text-base">Available</SelectItem>
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
<SelectItem value="Available" className="text-sm">Available</SelectItem>
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" && (
<div className="flex flex-col gap-1.5">
<Input
placeholder="Batch no."
value={line.batchNo}
aria-invalid={!!errors.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
className="h-9 text-sm"
/>
<Input
type="date"
value={line.expiryDate}
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
className="h-9 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
<TableCell className="py-3 pr-0 pl-1 align-top">
{item?.warranty === "Warranty" ? (
<div className="relative flex flex-col gap-1">
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setLineTab("warranty")}
aria-label="Add warranty numbers"
/>
}
>
<ShieldCheck className="size-4" />
</TooltipTrigger>
<TooltipContent>Add warranty numbers</TooltipContent>
</Tooltip>
{errors.warrantyNumbers && (
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
)}
<WarrantyHintBubble />
</div>
)}
{item?.trackingMode === "Serial" && (
<div className="flex flex-col gap-1.5">
<textarea
placeholder="One serial per line"
value={line.serialNumbersText}
aria-invalid={!!errors.serialNumbers}
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
</div>
)}
{(!item || item.trackingMode === "None") && (
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<TableCell className="py-3 pr-3 pl-0 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" />
</Button>
@@ -630,7 +711,86 @@ export default function NewGrnPage() {
</div>
)}
{!poLoading && lines.length > 0 && (
{!poLoading && lineTab === "warranty" && (
<div className="flex flex-col gap-3">
<Button
type="button"
variant="outline"
size="sm"
className="w-fit self-end rounded-full"
onClick={() => setLineTab("lines")}
>
<ArrowLeft className="size-4" />
Back to lines
</Button>
{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.
</p>
) : (
<div className="flex flex-col gap-6">
{warrantyLines.map(({ line, item, units }) => {
const errors = lineErrors[line.key] ?? {}
return (
<div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground">
{item ? `${item.sku}${item.name}` : `Item #${line.itemId}`}
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: units }, (_, i) => (
<TableRow key={`${line.key}-${i}`}>
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
{i + 1} of {units}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
value={line.warrantyNumbers[i] ?? ""}
aria-invalid={!!errors.warrantyNumbers}
onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)}
placeholder="e.g. WTY-000123"
className="h-10 max-w-xs text-sm"
/>
{i === units - 1 && (
<FieldError
errors={[errors.warrantyNumbers ? { message: errors.warrantyNumbers } : undefined]}
/>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
})}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-fit self-end rounded-full"
onClick={() => setLineTab("lines")}
>
<ArrowLeft className="size-4" />
Back to lines
</Button>
</div>
)}
{!poLoading && lineTab === "lines" && lines.length > 0 && (
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
<span className="text-muted-foreground">Document total (incl. VAT)</span>
<span className="font-semibold tabular-nums">
@@ -644,14 +804,16 @@ export default function NewGrnPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
)}
<div className="flex justify-end gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
{lineTab === "lines" && (
<div className="flex justify-end gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" type="button" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create GRN"}
</Button>
</div>
)}
</>
)}
</div>
+11 -23
View File
@@ -3,7 +3,7 @@
// (over-receipt tolerance, referential existence, concurrency) are never
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
import { z } from "zod"
import { TrackingMode } from "@/types/master-data"
import { Warranty } from "@/types/master-data"
export const grnHeaderSchema = z.object({
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
@@ -17,9 +17,8 @@ export function validateLine(input: {
unitCost: string
discountPct: string
vatPct: string
trackingMode: TrackingMode | null
batchNo: string
serialNumbersText: string
warranty: Warranty | null
warrantyNumbers: string[]
}): Record<string, string> {
const errors: Record<string, string> = {}
@@ -39,27 +38,16 @@ export function validateLine(input: {
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0100%"
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
errors.batchNo = "Batch number is required for this item"
}
if (input.trackingMode === "Serial") {
const serials = splitSerials(input.serialNumbersText)
if (serials.length === 0) {
errors.serialNumbers = "Enter one serial number per unit"
} else if (!Number.isNaN(qty) && serials.length !== qty) {
errors.serialNumbers = `Enter exactly ${qty || 0} serial number(s) — got ${serials.length}`
} else if (new Set(serials).size !== serials.length) {
errors.serialNumbers = "Serial numbers must be unique"
if (input.warranty === "Warranty") {
const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
if (numbers.length === 0) {
errors.warrantyNumbers = "Enter one warranty number per unit"
} else if (!Number.isNaN(qty) && numbers.length !== qty) {
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}`
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
errors.warrantyNumbers = "Warranty numbers must be unique"
}
}
return errors
}
export function splitSerials(text: string): string[] {
return text
.split(/[\n,]/)
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
+15
View File
@@ -22,6 +22,15 @@ export interface BatchInput {
expiryDate?: string | null
}
/**
* A captured warranty number as returned by the server. The coverage period is not chosen at
* receipt — it is snapshotted from the item's own `warrantyPeriodMonths` (set at item create).
*/
export interface GrnLineWarrantyNumber {
warrantyNo: string
warrantyPeriodMonths: number
}
/** One received line on create (docs/11 §4.1). */
export interface CreateGrnLineInput {
poLineId?: number | null
@@ -40,6 +49,11 @@ export interface CreateGrnLineInput {
vatPct?: number
holdStatus: HoldStatus
batch?: BatchInput | null
/**
* Required, one per received unit (count must equal `qty`), when the item is
* warranty-tracked (`Item.warranty === "Warranty"`). Ignored otherwise.
*/
warrantyNumbers?: string[] | null
}
export interface CreateGrnRequest {
@@ -73,6 +87,7 @@ export interface GrnLine {
priceVariance: number
holdStatus: HoldStatus
batchId: number | null
warrantyNumbers: GrnLineWarrantyNumber[]
}
export interface Grn {
+15
View File
@@ -9,6 +9,11 @@ import { EntityStatus } from "@/types/common"
*/
export type StockNature = "Stocked" | "NonStocked" | "Service"
export type TrackingMode = "None" | "Batch" | "Serial"
export type Warranty = "NonWarranty" | "Warranty"
/** Warranty coverage lengths offered when an item is marked Warranty. */
export const WARRANTY_PERIOD_MONTHS_OPTIONS = [3, 6, 12, 18] as const
export type WarrantyPeriodMonths = (typeof WARRANTY_PERIOD_MONTHS_OPTIONS)[number]
/**
* Unit of an item's content size — how much one stocked pack holds.
@@ -40,6 +45,9 @@ export interface ItemListItem extends ItemContent {
defaultVendorId: number | null
stockNature: StockNature
trackingMode: TrackingMode
warranty: Warranty
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
@@ -72,6 +80,9 @@ export interface Item extends ItemContent {
defaultVendorId: number | null
stockNature: StockNature
trackingMode: TrackingMode
warranty: Warranty
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
warrantyPeriodMonths: WarrantyPeriodMonths | null
taxClass: string | null
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
salePrice: number | null
@@ -95,6 +106,10 @@ export interface CreateItemRequest {
defaultVendorId?: number | null
stockNature: StockNature
trackingMode: TrackingMode
/** Defaults to `NonWarranty` server-side when omitted. */
warranty?: Warranty
/** Required (one of `WARRANTY_PERIOD_MONTHS_OPTIONS`) when `warranty` is `"Warranty"`; ignored otherwise. */
warrantyPeriodMonths?: WarrantyPeriodMonths | null
taxClass?: string | null
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
salePrice?: number | null