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:
@@ -58,4 +58,11 @@ public class GrnLine
|
|||||||
public decimal LineTotal { get; set; }
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||||
|
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||||
|
/// Empty for a non-warranty item.
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One warranty number captured against a single received unit of a warranty-tracked
|
||||||
|
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
|
||||||
|
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
|
||||||
|
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
|
||||||
|
public int WarrantyPeriodMonths { get; set; }
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ public class Item
|
|||||||
|
|
||||||
public StockNature StockNature { get; set; }
|
public StockNature StockNature { get; set; }
|
||||||
public TrackingMode TrackingMode { get; set; }
|
public TrackingMode TrackingMode { get; set; }
|
||||||
|
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||||
|
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||||
|
public int? WarrantyPeriodMonths { get; set; }
|
||||||
public string? TaxClass { get; set; }
|
public string? TaxClass { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
|
||||||
|
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum Warranty
|
||||||
|
{
|
||||||
|
NonWarranty,
|
||||||
|
Warranty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
|
||||||
|
public static class WarrantyPeriods
|
||||||
|
{
|
||||||
|
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ namespace ERPCore.Dtos.Grn;
|
|||||||
|
|
||||||
// Responses (docs/11 §4) --------------------------------------------------------
|
// Responses (docs/11 §4) --------------------------------------------------------
|
||||||
|
|
||||||
|
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
|
||||||
|
|
||||||
public sealed record GrnLineDto(
|
public sealed record GrnLineDto(
|
||||||
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
||||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||||
HoldStatus HoldStatus, int? BatchId);
|
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||||
|
|
||||||
public sealed record GrnDto(
|
public sealed record GrnDto(
|
||||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
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; }
|
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||||
public BatchInput? Batch { get; set; }
|
public BatchInput? Batch { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
|
||||||
|
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? WarrantyNumbers { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CreateGrnRequest
|
public sealed class CreateGrnRequest
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace ERPCore.Dtos.Items;
|
|||||||
public sealed record ItemListItemDto(
|
public sealed record ItemListItemDto(
|
||||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||||
|
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||||
string? TaxClass, decimal? SalePrice,
|
string? TaxClass, decimal? SalePrice,
|
||||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||||
@@ -29,6 +30,7 @@ public sealed record ItemDetailDto(
|
|||||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||||
StockNature StockNature, TrackingMode TrackingMode,
|
StockNature StockNature, TrackingMode TrackingMode,
|
||||||
|
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||||
string? TaxClass, decimal? SalePrice,
|
string? TaxClass, decimal? SalePrice,
|
||||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||||
@@ -59,6 +61,9 @@ public sealed class CreateItemRequest
|
|||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
|
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||||
|
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||||
|
public int? WarrantyPeriodMonths { get; set; }
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[StringLength(20)] public string? TaxClass { get; set; }
|
||||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||||
@@ -86,6 +91,9 @@ public sealed class UpdateItemRequest
|
|||||||
public int? DefaultVendorId { get; set; }
|
public int? DefaultVendorId { get; set; }
|
||||||
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
|
||||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||||
|
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||||
|
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
|
||||||
|
public int? WarrantyPeriodMonths { get; set; }
|
||||||
[StringLength(20)] public string? TaxClass { get; set; }
|
[StringLength(20)] public string? TaxClass { get; set; }
|
||||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||||
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
|
||||||
|
|||||||
@@ -53,3 +53,21 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
|||||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguration<GrnLineWarrantyNumber>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<GrnLineWarrantyNumber> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
|
|||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
builder.Property(i => i.TrackingMode)
|
builder.Property(i => i.TrackingMode)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
.HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(i => i.Warranty)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(Warranty.NonWarranty);
|
||||||
builder.Property(i => i.Status)
|
builder.Property(i => i.Status)
|
||||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
.HasDefaultValue(EntityStatus.Active);
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public sealed class GrnService : IGrnService
|
|||||||
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var grn = await _grns.Query().AsNoTracking()
|
var grn = await _grns.Query().AsNoTracking()
|
||||||
.Include(g => g.Lines)
|
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||||
return grn is null ? null : Map(grn);
|
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 vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||||
|
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
|
||||||
|
|
||||||
lines.Add(new GrnLine
|
lines.Add(new GrnLine
|
||||||
{
|
{
|
||||||
@@ -181,7 +182,8 @@ public sealed class GrnService : IGrnService
|
|||||||
VatAmount = vatAmount,
|
VatAmount = vatAmount,
|
||||||
ReceivedValue = receivedValue,
|
ReceivedValue = receivedValue,
|
||||||
LineTotal = receivedValue + vatAmount,
|
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
|
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A warranty-tracked item requires exactly one warranty number per received unit —
|
||||||
|
/// same shape of rule as <see cref="ResolveBatchAsync"/> for batch tracking. The coverage
|
||||||
|
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
|
||||||
|
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
|
||||||
|
/// </summary>
|
||||||
|
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty)
|
||||||
|
{
|
||||||
|
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
|
||||||
|
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<string>()).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)
|
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (poId is null) return;
|
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.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.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.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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public sealed class ItemService : IItemService
|
|||||||
.Select(i => new ItemListItemDto(
|
.Select(i => new ItemListItemDto(
|
||||||
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.BaseUomId, i.DefaultVendorId,
|
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.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||||
i.Status))
|
i.Status))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
@@ -113,6 +113,7 @@ public sealed class ItemService : IItemService
|
|||||||
|
|
||||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||||
|
var warrantyPeriodMonths = ValidateWarrantyPeriod(request.Warranty, request.WarrantyPeriodMonths);
|
||||||
|
|
||||||
var item = new Item
|
var item = new Item
|
||||||
{
|
{
|
||||||
@@ -126,6 +127,8 @@ public sealed class ItemService : IItemService
|
|||||||
DefaultVendorId = request.DefaultVendorId,
|
DefaultVendorId = request.DefaultVendorId,
|
||||||
StockNature = request.StockNature,
|
StockNature = request.StockNature,
|
||||||
TrackingMode = request.TrackingMode,
|
TrackingMode = request.TrackingMode,
|
||||||
|
Warranty = request.Warranty,
|
||||||
|
WarrantyPeriodMonths = warrantyPeriodMonths,
|
||||||
TaxClass = request.TaxClass,
|
TaxClass = request.TaxClass,
|
||||||
SalePrice = request.SalePrice,
|
SalePrice = request.SalePrice,
|
||||||
ContentQty = request.ContentQty,
|
ContentQty = request.ContentQty,
|
||||||
@@ -172,6 +175,7 @@ public sealed class ItemService : IItemService
|
|||||||
|
|
||||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(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.Sku = request.Sku.Trim();
|
||||||
item.Name = request.Name.Trim();
|
item.Name = request.Name.Trim();
|
||||||
@@ -183,6 +187,8 @@ public sealed class ItemService : IItemService
|
|||||||
item.DefaultVendorId = request.DefaultVendorId;
|
item.DefaultVendorId = request.DefaultVendorId;
|
||||||
item.StockNature = request.StockNature;
|
item.StockNature = request.StockNature;
|
||||||
item.TrackingMode = request.TrackingMode;
|
item.TrackingMode = request.TrackingMode;
|
||||||
|
item.Warranty = request.Warranty;
|
||||||
|
item.WarrantyPeriodMonths = warrantyPeriodMonths;
|
||||||
item.TaxClass = request.TaxClass;
|
item.TaxClass = request.TaxClass;
|
||||||
item.SalePrice = request.SalePrice;
|
item.SalePrice = request.SalePrice;
|
||||||
item.ContentQty = request.ContentQty;
|
item.ContentQty = request.ContentQty;
|
||||||
@@ -326,6 +332,23 @@ public sealed class ItemService : IItemService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A warranty-tracked item must declare a coverage period (one of
|
||||||
|
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||||
|
/// any value sent for one is silently dropped rather than trusted from the client.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -341,7 +364,7 @@ public sealed class ItemService : IItemService
|
|||||||
private static ItemDetailDto ToDetail(Item i) => new(
|
private static ItemDetailDto ToDetail(Item i) => new(
|
||||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||||
i.BaseUomId, i.DefaultVendorId,
|
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.ContentQty, i.ContentUnit, i.ContentBaseQty, i.ContentBaseUnit,
|
||||||
i.Status,
|
i.Status,
|
||||||
i.ReorderSettings
|
i.ReorderSettings
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
|||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
import { validateItemForm } from "@/lib/validations/master-data"
|
import { validateItemForm } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
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. */
|
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
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.
|
// carried through unchanged (from the loaded item) so a save doesn't silently clear them.
|
||||||
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
const [defaultVendorId, setDefaultVendorId] = useState<number | null>(null)
|
||||||
const [trackingMode, setTrackingMode] = useState<TrackingMode>("None")
|
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("")
|
const [taxClass, setTaxClass] = useState("")
|
||||||
// Raw string: an empty box means "no content size", which is not the same as 0.
|
// Raw string: an empty box means "no content size", which is not the same as 0.
|
||||||
const [contentQty, setContentQty] = useState("")
|
const [contentQty, setContentQty] = useState("")
|
||||||
@@ -80,6 +83,8 @@ export default function ItemDetailPage() {
|
|||||||
setDefaultVendorId(data.defaultVendorId)
|
setDefaultVendorId(data.defaultVendorId)
|
||||||
setStockNature(data.stockNature)
|
setStockNature(data.stockNature)
|
||||||
setTrackingMode(data.trackingMode)
|
setTrackingMode(data.trackingMode)
|
||||||
|
setWarranty(data.warranty)
|
||||||
|
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
|
||||||
setTaxClass(data.taxClass ?? "")
|
setTaxClass(data.taxClass ?? "")
|
||||||
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
|
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
|
||||||
setContentUnit(data.contentUnit)
|
setContentUnit(data.contentUnit)
|
||||||
@@ -123,7 +128,7 @@ export default function ItemDetailPage() {
|
|||||||
item.itemId,
|
item.itemId,
|
||||||
{
|
{
|
||||||
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
|
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,
|
taxClass: taxClass || null,
|
||||||
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
contentQty: contentQty.trim() ? Number(contentQty) : null,
|
||||||
contentUnit: contentQty.trim() ? contentUnit : null,
|
contentUnit: contentQty.trim() ? contentUnit : null,
|
||||||
|
|||||||
@@ -20,7 +20,18 @@ import {
|
|||||||
validateVariantPrices,
|
validateVariantPrices,
|
||||||
} from "@/lib/validations/master-data"
|
} from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
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. */
|
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||||
@@ -125,6 +136,10 @@ export default function NewItemPage() {
|
|||||||
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
|
const [pricesByKey, setPricesByKey] = useState<Record<string, string>>({})
|
||||||
const [priceErrors, setPriceErrors] = 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 [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -340,6 +355,9 @@ export default function NewItemPage() {
|
|||||||
if (measurableCheckedCount > 1) {
|
if (measurableCheckedCount > 1) {
|
||||||
nextErrors.measurable = "Only one measurement dimension can be used at a time."
|
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.
|
// Should never fire — addValue is the real gate — so it catches stale state only.
|
||||||
const contentSweep = validateVariantContent(
|
const contentSweep = validateVariantContent(
|
||||||
variants.map((v) => v.key),
|
variants.map((v) => v.key),
|
||||||
@@ -381,6 +399,8 @@ export default function NewItemPage() {
|
|||||||
baseUomId,
|
baseUomId,
|
||||||
stockNature,
|
stockNature,
|
||||||
trackingMode: "None",
|
trackingMode: "None",
|
||||||
|
warranty,
|
||||||
|
warrantyPeriodMonths: warranty === "Warranty" ? warrantyPeriodMonths : null,
|
||||||
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
|
salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null,
|
||||||
// Each variant carries its OWN size when a measurement dimension supplied one;
|
// 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
|
// otherwise the shared form-level pair, which is correct when the varying dimension
|
||||||
@@ -672,6 +692,64 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||||
item-type reference), so this section IS the enforcement. */}
|
item-type reference), so this section IS the enforcement. */}
|
||||||
{config?.itemTypesEnabled && (
|
{config?.itemTypesEnabled && (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -176,7 +177,20 @@ export default function GrnDetailPage() {
|
|||||||
const item = itemFor(line.itemId)
|
const item = itemFor(line.itemId)
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.grnLineId}>
|
<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">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
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 { grnsApi } from "@/lib/api/grns"
|
||||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
@@ -13,12 +13,13 @@ import { itemsApi } from "@/lib/api/items"
|
|||||||
import { uomsApi } from "@/lib/api/uoms"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
import { baseUomLabel } from "@/lib/uom-label"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
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 { cn } from "@/lib/utils"
|
||||||
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
import { CreateGrnLineInput, HoldStatus } from "@/types/grn"
|
||||||
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
import { PurchaseOrder, PurchaseOrderSummary } from "@/types/procurement"
|
||||||
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
import { Bin, ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
type Mode = "po" | "direct"
|
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 {
|
interface DraftLine {
|
||||||
key: string
|
key: string
|
||||||
@@ -42,9 +76,8 @@ interface DraftLine {
|
|||||||
discountPct: string
|
discountPct: string
|
||||||
vatPct: string
|
vatPct: string
|
||||||
holdStatus: HoldStatus
|
holdStatus: HoldStatus
|
||||||
batchNo: string
|
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
|
||||||
expiryDate: string
|
warrantyNumbers: string[]
|
||||||
serialNumbersText: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
|
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
|
||||||
@@ -77,9 +110,7 @@ function emptyLine(): DraftLine {
|
|||||||
discountPct: "0",
|
discountPct: "0",
|
||||||
vatPct: "0",
|
vatPct: "0",
|
||||||
holdStatus: "Available",
|
holdStatus: "Available",
|
||||||
batchNo: "",
|
warrantyNumbers: [],
|
||||||
expiryDate: "",
|
|
||||||
serialNumbersText: "",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +132,7 @@ export default function NewGrnPage() {
|
|||||||
const [poId, setPoId] = useState<number | null>(null)
|
const [poId, setPoId] = useState<number | null>(null)
|
||||||
const [poLoading, setPoLoading] = useState(false)
|
const [poLoading, setPoLoading] = useState(false)
|
||||||
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
|
||||||
|
const [lineTab, setLineTab] = useState<LineTab>("lines")
|
||||||
|
|
||||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||||
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
const [lineErrors, setLineErrors] = useState<Record<string, Record<string, string>>>({})
|
||||||
@@ -174,9 +206,7 @@ export default function NewGrnPage() {
|
|||||||
discountPct: "0",
|
discountPct: "0",
|
||||||
vatPct: "0",
|
vatPct: "0",
|
||||||
holdStatus: "Available",
|
holdStatus: "Available",
|
||||||
batchNo: "",
|
warrantyNumbers: [],
|
||||||
expiryDate: "",
|
|
||||||
serialNumbersText: "",
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -214,6 +244,24 @@ export default function NewGrnPage() {
|
|||||||
return items?.find((i) => i.itemId === itemId) ?? null
|
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() {
|
async function handleSubmit() {
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setHeaderError(null)
|
setHeaderError(null)
|
||||||
@@ -242,26 +290,31 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
const nextLineErrors: Record<string, Record<string, string>> = {}
|
const nextLineErrors: Record<string, Record<string, string>> = {}
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
const item = itemFor(line.itemId)
|
||||||
const errors = validateLine({
|
const errors = validateLine({
|
||||||
itemId: line.itemId,
|
itemId: line.itemId,
|
||||||
qty: line.qty,
|
qty: line.qty,
|
||||||
unitCost: line.unitCost,
|
unitCost: line.unitCost,
|
||||||
discountPct: line.discountPct,
|
discountPct: line.discountPct,
|
||||||
vatPct: line.vatPct,
|
vatPct: line.vatPct,
|
||||||
trackingMode: itemFor(line.itemId)?.trackingMode ?? null,
|
warranty: item?.warranty ?? null,
|
||||||
batchNo: line.batchNo,
|
warrantyNumbers: line.warrantyNumbers,
|
||||||
serialNumbersText: line.serialNumbersText,
|
|
||||||
})
|
})
|
||||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||||
}
|
}
|
||||||
setLineErrors(nextLineErrors)
|
setLineErrors(nextLineErrors)
|
||||||
if (Object.keys(nextLineErrors).length > 0) {
|
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.")
|
setSubmitError("Fix the highlighted lines before submitting.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
||||||
const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None"
|
const item = itemFor(l.itemId)
|
||||||
return {
|
return {
|
||||||
poLineId: l.poLineId,
|
poLineId: l.poLineId,
|
||||||
itemId: l.itemId as number,
|
itemId: l.itemId as number,
|
||||||
@@ -271,8 +324,10 @@ export default function NewGrnPage() {
|
|||||||
discountPct: Number(l.discountPct) || 0,
|
discountPct: Number(l.discountPct) || 0,
|
||||||
vatPct: Number(l.vatPct) || 0,
|
vatPct: Number(l.vatPct) || 0,
|
||||||
holdStatus: l.holdStatus,
|
holdStatus: l.holdStatus,
|
||||||
batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null,
|
warrantyNumbers:
|
||||||
serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null,
|
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>
|
</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 && <Skeleton className="h-24 w-full" />}
|
||||||
|
|
||||||
{!poLoading && lines.length > 0 && (
|
{!poLoading && lineTab === "lines" && lines.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="text-base">
|
<Table className="text-sm">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
<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-24 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</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-88 px-3 text-sm">Qty</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</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-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-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" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -460,40 +541,47 @@ export default function NewGrnPage() {
|
|||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{line.poLineId ? (
|
{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 ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||||
|
{item?.warranty === "Warranty" && (
|
||||||
|
<Badge variant="outline" className="text-xs">Warranty</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<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" />
|
<SelectValue placeholder="Select item" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(items ?? []).map((i) => (
|
{(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.sku} — {i.name}
|
||||||
|
{i.warranty === "Warranty" ? " (Warranty)" : ""}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{item?.warranty === "Warranty" && (
|
||||||
|
<Badge variant="outline" className="mt-1.5 text-xs">Warranty</Badge>
|
||||||
|
)}
|
||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<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)}
|
{baseUomLabel(items ?? [], uoms ?? [], line.itemId)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
<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" />
|
<SelectValue placeholder="None" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{bins.map((b) => (
|
{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}
|
{b.code}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -508,7 +596,7 @@ export default function NewGrnPage() {
|
|||||||
value={line.qty}
|
value={line.qty}
|
||||||
aria-invalid={!!errors.qty}
|
aria-invalid={!!errors.qty}
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
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]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -520,7 +608,7 @@ export default function NewGrnPage() {
|
|||||||
value={line.unitCost}
|
value={line.unitCost}
|
||||||
aria-invalid={!!errors.unitCost}
|
aria-invalid={!!errors.unitCost}
|
||||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
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]} />
|
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||||
@@ -538,7 +626,7 @@ export default function NewGrnPage() {
|
|||||||
value={line.discountPct}
|
value={line.discountPct}
|
||||||
aria-invalid={!!errors.discountPct}
|
aria-invalid={!!errors.discountPct}
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
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]} />
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -551,7 +639,7 @@ export default function NewGrnPage() {
|
|||||||
value={line.vatPct}
|
value={line.vatPct}
|
||||||
aria-invalid={!!errors.vatPct}
|
aria-invalid={!!errors.vatPct}
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
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]} />
|
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -573,51 +661,44 @@ export default function NewGrnPage() {
|
|||||||
value={line.holdStatus}
|
value={line.holdStatus}
|
||||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
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 />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
<SelectItem value="Available" className="text-sm">Available</SelectItem>
|
||||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
<SelectItem value="OnHold" className="text-sm">On hold (inspection)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="py-3 pr-0 pl-1 align-top">
|
||||||
{item?.trackingMode === "Batch" && (
|
{item?.warranty === "Warranty" ? (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="relative flex flex-col gap-1">
|
||||||
<Input
|
<Tooltip>
|
||||||
placeholder="Batch no."
|
<TooltipTrigger
|
||||||
value={line.batchNo}
|
render={
|
||||||
aria-invalid={!!errors.batchNo}
|
<Button
|
||||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
type="button"
|
||||||
className="h-9 text-sm"
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setLineTab("warranty")}
|
||||||
|
aria-label="Add warranty numbers"
|
||||||
/>
|
/>
|
||||||
<Input
|
}
|
||||||
type="date"
|
>
|
||||||
value={line.expiryDate}
|
<ShieldCheck className="size-4" />
|
||||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
</TooltipTrigger>
|
||||||
className="h-9 text-sm"
|
<TooltipContent>Add warranty numbers</TooltipContent>
|
||||||
/>
|
</Tooltip>
|
||||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
{errors.warrantyNumbers && (
|
||||||
</div>
|
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
|
||||||
)}
|
)}
|
||||||
{item?.trackingMode === "Serial" && (
|
<WarrantyHintBubble />
|
||||||
<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>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{(!item || item.trackingMode === "None") && (
|
|
||||||
<span className="text-sm text-muted-foreground">—</span>
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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">
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -630,7 +711,86 @@ export default function NewGrnPage() {
|
|||||||
</div>
|
</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">
|
<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="text-muted-foreground">Document total (incl. VAT)</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
@@ -644,6 +804,7 @@ 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="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lineTab === "lines" && (
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -652,6 +813,7 @@ export default function NewGrnPage() {
|
|||||||
{submitting ? "Creating…" : "Create GRN"}
|
{submitting ? "Creating…" : "Create GRN"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// (over-receipt tolerance, referential existence, concurrency) are never
|
// (over-receipt tolerance, referential existence, concurrency) are never
|
||||||
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { TrackingMode } from "@/types/master-data"
|
import { Warranty } from "@/types/master-data"
|
||||||
|
|
||||||
export const grnHeaderSchema = z.object({
|
export const grnHeaderSchema = z.object({
|
||||||
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
|
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
|
||||||
@@ -17,9 +17,8 @@ export function validateLine(input: {
|
|||||||
unitCost: string
|
unitCost: string
|
||||||
discountPct: string
|
discountPct: string
|
||||||
vatPct: string
|
vatPct: string
|
||||||
trackingMode: TrackingMode | null
|
warranty: Warranty | null
|
||||||
batchNo: string
|
warrantyNumbers: string[]
|
||||||
serialNumbersText: string
|
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
const errors: 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))
|
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
|
||||||
errors.vatPct = "VAT must be 0–100%"
|
errors.vatPct = "VAT must be 0–100%"
|
||||||
|
|
||||||
if (input.trackingMode === "Batch" && !input.batchNo.trim()) {
|
if (input.warranty === "Warranty") {
|
||||||
errors.batchNo = "Batch number is required for this item"
|
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"
|
||||||
if (input.trackingMode === "Serial") {
|
} else if (!Number.isNaN(qty) && numbers.length !== qty) {
|
||||||
const serials = splitSerials(input.serialNumbersText)
|
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}`
|
||||||
if (serials.length === 0) {
|
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
|
||||||
errors.serialNumbers = "Enter one serial number per unit"
|
errors.warrantyNumbers = "Warranty numbers must be unique"
|
||||||
} 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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitSerials(text: string): string[] {
|
|
||||||
return text
|
|
||||||
.split(/[\n,]/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface BatchInput {
|
|||||||
expiryDate?: string | null
|
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). */
|
/** One received line on create (docs/11 §4.1). */
|
||||||
export interface CreateGrnLineInput {
|
export interface CreateGrnLineInput {
|
||||||
poLineId?: number | null
|
poLineId?: number | null
|
||||||
@@ -40,6 +49,11 @@ export interface CreateGrnLineInput {
|
|||||||
vatPct?: number
|
vatPct?: number
|
||||||
holdStatus: HoldStatus
|
holdStatus: HoldStatus
|
||||||
batch?: BatchInput | null
|
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 {
|
export interface CreateGrnRequest {
|
||||||
@@ -73,6 +87,7 @@ export interface GrnLine {
|
|||||||
priceVariance: number
|
priceVariance: number
|
||||||
holdStatus: HoldStatus
|
holdStatus: HoldStatus
|
||||||
batchId: number | null
|
batchId: number | null
|
||||||
|
warrantyNumbers: GrnLineWarrantyNumber[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Grn {
|
export interface Grn {
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { EntityStatus } from "@/types/common"
|
|||||||
*/
|
*/
|
||||||
export type StockNature = "Stocked" | "NonStocked" | "Service"
|
export type StockNature = "Stocked" | "NonStocked" | "Service"
|
||||||
export type TrackingMode = "None" | "Batch" | "Serial"
|
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.
|
* 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
|
defaultVendorId: number | null
|
||||||
stockNature: StockNature
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
trackingMode: TrackingMode
|
||||||
|
warranty: Warranty
|
||||||
|
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||||
|
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||||
taxClass: string | null
|
taxClass: string | null
|
||||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||||
salePrice: number | null
|
salePrice: number | null
|
||||||
@@ -72,6 +80,9 @@ export interface Item extends ItemContent {
|
|||||||
defaultVendorId: number | null
|
defaultVendorId: number | null
|
||||||
stockNature: StockNature
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
trackingMode: TrackingMode
|
||||||
|
warranty: Warranty
|
||||||
|
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||||
|
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||||
taxClass: string | null
|
taxClass: string | null
|
||||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||||
salePrice: number | null
|
salePrice: number | null
|
||||||
@@ -95,6 +106,10 @@ export interface CreateItemRequest {
|
|||||||
defaultVendorId?: number | null
|
defaultVendorId?: number | null
|
||||||
stockNature: StockNature
|
stockNature: StockNature
|
||||||
trackingMode: TrackingMode
|
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
|
taxClass?: string | null
|
||||||
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
|
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
|
||||||
salePrice?: number | null
|
salePrice?: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user