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