+
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
@@ -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"
/>
{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"
/>
@@ -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"
/>
@@ -573,51 +661,44 @@ export default function NewGrnPage() {
value={line.holdStatus}
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
>
-
+
- Available
- On hold (inspection)
+ Available
+ On hold (inspection)
-
- {item?.trackingMode === "Batch" && (
-
-
updateLine(line.key, { batchNo: e.target.value })}
- className="h-9 text-sm"
- />
-
updateLine(line.key, { expiryDate: e.target.value })}
- className="h-9 text-sm"
- />
-
+
+ {item?.warranty === "Warranty" ? (
+
+
+ setLineTab("warranty")}
+ aria-label="Add warranty numbers"
+ />
+ }
+ >
+
+
+ Add warranty numbers
+
+ {errors.warrantyNumbers && (
+
+ )}
+
- )}
- {item?.trackingMode === "Serial" && (
-
-
- )}
- {(!item || item.trackingMode === "None") && (
+ ) : (
—
)}
-
+
@@ -630,7 +711,86 @@ export default function NewGrnPage() {
)}
- {!poLoading && lines.length > 0 && (
+ {!poLoading && lineTab === "warranty" && (
+
+
+
+ {warrantyLines.length === 0 ? (
+
+ No warranty-tracked items on this GRN yet — add a line for an item marked
+ “Warranty” to capture its numbers here.
+
+ ) : (
+
+ {warrantyLines.map(({ line, item, units }) => {
+ const errors = lineErrors[line.key] ?? {}
+ return (
+
+
+ {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ )}
+
+ {!poLoading && lineTab === "lines" && lines.length > 0 && (
Document total (incl. VAT)
@@ -644,14 +804,16 @@ export default function NewGrnPage() {
{submitError}
)}
-
-
- Cancel
-
-
-
+ {lineTab === "lines" && (
+
+
+ Cancel
+
+
+
+ )}
>
)}
diff --git a/Frontend/erp-system/lib/validations/grn.ts b/Frontend/erp-system/lib/validations/grn.ts
index bb55166..8574ce9 100644
--- a/Frontend/erp-system/lib/validations/grn.ts
+++ b/Frontend/erp-system/lib/validations/grn.ts
@@ -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 {
const errors: Record = {}
@@ -39,27 +38,16 @@ export function validateLine(input: {
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0–100%"
- 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)
-}
diff --git a/Frontend/erp-system/types/grn.ts b/Frontend/erp-system/types/grn.ts
index 2e612a9..5348837 100644
--- a/Frontend/erp-system/types/grn.ts
+++ b/Frontend/erp-system/types/grn.ts
@@ -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 {
diff --git a/Frontend/erp-system/types/master-data.ts b/Frontend/erp-system/types/master-data.ts
index 3232d37..426c048 100644
--- a/Frontend/erp-system/types/master-data.ts
+++ b/Frontend/erp-system/types/master-data.ts
@@ -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