diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index c6094dd..74ed6d1 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -58,4 +58,11 @@ public class GrnLine public decimal LineTotal { get; set; } public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; + + /// + /// One row per received unit when is + /// — count must equal . + /// Empty for a non-warranty item. + /// + public ICollection WarrantyNumbers { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/GrnLineWarrantyNumber.cs b/Backend/ERPCore/Domain/Entities/GrnLineWarrantyNumber.cs new file mode 100644 index 0000000..d736ab0 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/GrnLineWarrantyNumber.cs @@ -0,0 +1,20 @@ +namespace ERPCore.Domain.Entities; + +/// +/// One warranty number captured against a single received unit of a warranty-tracked +/// item ( = ). A GRN line +/// for such an item must carry exactly of these — one per unit — +/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3). +/// +public class GrnLineWarrantyNumber +{ + public int GrnLineWarrantyNumberId { get; set; } + + public int GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public string WarrantyNo { get; set; } = string.Empty; + + /// Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18). + public int WarrantyPeriodMonths { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index 9b39ae5..94b22a7 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -38,6 +38,9 @@ public class Item public StockNature StockNature { get; set; } public TrackingMode TrackingMode { get; set; } + public Warranty Warranty { get; set; } = Warranty.NonWarranty; + /// Coverage length in months (see ) when is Warranty; null otherwise. + public int? WarrantyPeriodMonths { get; set; } public string? TaxClass { get; set; } /// diff --git a/Backend/ERPCore/Domain/Enums/Warranty.cs b/Backend/ERPCore/Domain/Enums/Warranty.cs new file mode 100644 index 0000000..fa28331 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/Warranty.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same +/// convention as and . +/// +public enum Warranty +{ + NonWarranty, + Warranty +} + +/// Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01). +public static class WarrantyPeriods +{ + public static readonly int[] AllowedMonths = { 3, 6, 12, 18 }; +} diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index 81889d8..83f2f1c 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -5,12 +5,14 @@ namespace ERPCore.Dtos.Grn; // Responses (docs/11 §4) -------------------------------------------------------- +public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths); + public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int? BinId, decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, - HoldStatus HoldStatus, int? BatchId); + HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, @@ -58,6 +60,11 @@ public sealed class CreateGrnLineInput [Range(0, 100)] public decimal VatPct { get; set; } [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; public BatchInput? Batch { get; set; } + /// + /// Required, one per unit (count must equal ), when the item is + /// warranty-tracked (Item.Warranty == Warranty.Warranty). Ignored otherwise. + /// + public List? WarrantyNumbers { get; set; } } public sealed class CreateGrnRequest diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index b61aec9..35f8d62 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -9,6 +9,7 @@ namespace ERPCore.Dtos.Items; public sealed record ItemListItemDto( int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, + Warranty Warranty, int? WarrantyPeriodMonths, string? TaxClass, decimal? SalePrice, decimal? ContentQty, MeasureUnit? ContentUnit, decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, @@ -29,6 +30,7 @@ public sealed record ItemDetailDto( int ItemId, string Sku, string Name, string? Description, int CategoryId, int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode, + Warranty Warranty, int? WarrantyPeriodMonths, string? TaxClass, decimal? SalePrice, decimal? ContentQty, MeasureUnit? ContentUnit, decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit, @@ -59,6 +61,9 @@ public sealed class CreateItemRequest public int? DefaultVendorId { get; set; } [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; + [EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty; + /// Required (one of ) when is Warranty; ignored otherwise. + public int? WarrantyPeriodMonths { get; set; } [StringLength(20)] public string? TaxClass { get; set; } /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } @@ -86,6 +91,9 @@ public sealed class UpdateItemRequest public int? DefaultVendorId { get; set; } [Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; + [EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty; + /// Required (one of ) when is Warranty; ignored otherwise. + public int? WarrantyPeriodMonths { get; set; } [StringLength(20)] public string? TaxClass { get; set; } /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value. [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index 1dbd77f..b003f20 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -53,3 +53,21 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); } } + +public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grn_line_warranty_numbers"); + builder.HasKey(w => w.GrnLineWarrantyNumberId); + + builder.Property(w => w.WarrantyNo).IsRequired().HasMaxLength(100); + + builder.HasOne(w => w.GrnLine).WithMany(l => l.WarrantyNumbers) + .HasForeignKey(w => w.GrnLineId).OnDelete(DeleteBehavior.Cascade); + + // A warranty number entered twice on the same line is almost certainly a typo — + // catch it at the DB, not just client-side. + builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index f519ae2..b14cb0e 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -36,6 +36,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.Warranty) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(Warranty.NonWarranty); builder.Property(i => i.Status) .HasConversion().HasMaxLength(20).IsRequired() .HasDefaultValue(EntityStatus.Active); diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index b526774..26ec228 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -91,7 +91,7 @@ public sealed class GrnService : IGrnService public async Task GetAsync(int grnId, CancellationToken ct = default) { var grn = await _grns.Query().AsNoTracking() - .Include(g => g.Lines) + .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); return grn is null ? null : Map(grn); } @@ -165,6 +165,7 @@ public sealed class GrnService : IGrnService var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero); var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); + var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty); lines.Add(new GrnLine { @@ -181,7 +182,8 @@ public sealed class GrnService : IGrnService VatAmount = vatAmount, ReceivedValue = receivedValue, LineTotal = receivedValue + vatAmount, - HoldStatus = input.HoldStatus + HoldStatus = input.HoldStatus, + WarrantyNumbers = warrantyNumbers }); } @@ -341,6 +343,30 @@ public sealed class GrnService : IGrnService return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges } + /// + /// A warranty-tracked item requires exactly one warranty number per received unit — + /// same shape of rule as for batch tracking. The coverage + /// period is not entered at receipt; it is snapshotted from , + /// which the item must have been given at creation ( enforces that). + /// + private static List ResolveWarrantyNumbers(Item item, List? numbers, decimal qty) + { + if (item.Warranty != Warranty.Warranty) return new List(); + if (item.WarrantyPeriodMonths is null) + throw new DomainException( + ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422); + + var trimmed = (numbers ?? new List()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + if (trimmed.Count != qty) + throw new DomainException( + ErrorCodes.Validation, + $"Item {item.Sku} is under warranty; provide exactly {qty} warranty number(s), got {trimmed.Count}.", 422); + if (trimmed.Distinct(StringComparer.OrdinalIgnoreCase).Count() != trimmed.Count) + throw new DomainException(ErrorCodes.Validation, $"Warranty numbers for item {item.Sku} must be unique.", 422); + + return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList(); + } + private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return; @@ -382,5 +408,6 @@ public sealed class GrnService : IGrnService l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice, l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), - l.HoldStatus, l.BatchId)).ToList()); + l.HoldStatus, l.BatchId, + l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList()); } diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index ad2e353..270a250 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -85,7 +85,7 @@ public sealed class ItemService : IItemService .Select(i => new ItemListItemDto( i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, + i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice, i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, i.Status)) .ToListAsync(ct); @@ -113,6 +113,7 @@ public sealed class ItemService : IItemService ItemContent.ValidatePair(request.ContentQty, request.ContentUnit); var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); + var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths); var item = new Item { @@ -126,6 +127,8 @@ public sealed class ItemService : IItemService DefaultVendorId = request.DefaultVendorId, StockNature = request.StockNature, TrackingMode = request.TrackingMode, + Warranty = request.Warranty, + WarrantyPeriodMonths = warrantyPeriodMonths, TaxClass = request.TaxClass, SalePrice = request.SalePrice, ContentQty = request.ContentQty, @@ -172,6 +175,7 @@ public sealed class ItemService : IItemService ItemContent.ValidatePair(request.ContentQty, request.ContentUnit); var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit); + var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths); item.Sku = request.Sku.Trim(); item.Name = request.Name.Trim(); @@ -183,6 +187,8 @@ public sealed class ItemService : IItemService item.DefaultVendorId = request.DefaultVendorId; item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; + item.Warranty = request.Warranty; + item.WarrantyPeriodMonths = warrantyPeriodMonths; item.TaxClass = request.TaxClass; item.SalePrice = request.SalePrice; item.ContentQty = request.ContentQty; @@ -326,6 +332,23 @@ public sealed class ItemService : IItemService } } + /// + /// A warranty-tracked item must declare a coverage period (one of + /// ); a non-warranty item carries none — + /// any value sent for one is silently dropped rather than trusted from the client. + /// + private static int? ValidateWarrantyPeriod(Warranty warranty, int? warrantyPeriodMonths) + { + if (warranty != Warranty.Warranty) return null; + + if (warrantyPeriodMonths is null || !WarrantyPeriods.AllowedMonths.Contains(warrantyPeriodMonths.Value)) + throw new DomainException( + ErrorCodes.Validation, + $"Warranty period must be one of {string.Join(", ", WarrantyPeriods.AllowedMonths)} months.", 422); + + return warrantyPeriodMonths; + } + private async Task SaveGuardingConcurrencyAsync(CancellationToken ct) { try @@ -341,7 +364,7 @@ public sealed class ItemService : IItemService private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, + i.StockNature, i.TrackingMode, i.Warranty, i.WarrantyPeriodMonths, i.TaxClass, i.SalePrice, i.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit, i.Status, i.ReorderSettings diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index f634204..be58162 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -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(null) const [trackingMode, setTrackingMode] = useState("None") + // Not editable here — carried through unchanged so a save doesn't silently reset it. + const [warranty, setWarranty] = useState("NonWarranty") + const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState(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, diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index cd45339..e8decf8 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -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>({}) const [priceErrors, setPriceErrors] = useState>({}) + // Warranty (FR-MD-01). Applies to every generated variant — there is no per-variant override. + const [warranty, setWarranty] = useState("NonWarranty") + const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState(null) + const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(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() { )} + {/* Warranty (FR-MD-01). Frontend-only toggle, mirrors the Sale price bar above. */} +
+
+

Warranty

+

+ Mark whether every generated variant is sold under warranty. +

+
+ +
+ + +
+ + {warranty === "Warranty" && ( +
+ + + value={warrantyPeriodMonths} + onValueChange={(v) => v && setWarrantyPeriodMonths(v)} + > + + + + + {WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => ( + + {months} months + + ))} + + + +
+ )} +
+ {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && ( diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index ab634b6..b75d4a3 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -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 ( - {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + +
+ {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {item?.warranty === "Warranty" && Warranty} +
+ {line.warrantyNumbers.length > 0 && ( +

`${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")} + > + {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured +

+ )} +
{baseUomLabel(items, uoms, line.itemId)} {binFor(line.binId)} {line.qty} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 30c40dc..e0d7c7f 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -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 ( +
+ + + + Add warranty numbers for this item +
+ ) +} 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(null) const [poLoading, setPoLoading] = useState(false) const [lines, setLines] = useState([emptyLine()]) + const [lineTab, setLineTab] = useState("lines") const [headerError, setHeaderError] = useState(null) const [lineErrors, setLineErrors] = useState>>({}) @@ -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> = {} 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() { +
+ + +
+ {poLoading && } - {!poLoading && lines.length > 0 && ( + {!poLoading && lineTab === "lines" && lines.length > 0 && (
- +
Item UOM Bin - Qty - Unit cost - Disc % - VAT % + Qty + Unit cost + Disc % + VAT % Line total Hold status - Batch / Serial + Action @@ -460,40 +541,47 @@ export default function NewGrnPage() { {line.poLineId ? ( -
+
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {item?.warranty === "Warranty" && ( + Warranty + )}
) : ( <> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> - + {(items ?? []).map((i) => ( - + {i.sku} — {i.name} + {i.warranty === "Warranty" ? " (Warranty)" : ""} ))} + {item?.warranty === "Warranty" && ( + Warranty + )} )} -
+
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> - + {bins.map((b) => ( - + {b.code} ))} @@ -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" /> @@ -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" && ( -
-