From 4e493f51f8c2e14b921f228365daf8d31210be93 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:03:45 +0530 Subject: [PATCH 01/16] Add item tracking and warranty settings --- Distribution/Data/AppDBContext.cs | 94 ++++++++++++++++++++++- Distribution/Features/Item/ItemDTOs.cs | 28 ++++++- Distribution/Features/Item/ItemService.cs | 62 ++++++++++++++- Distribution/Program.cs | 2 + 4 files changed, 179 insertions(+), 7 deletions(-) diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index c65d2e3..44b39fb 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -16,6 +16,8 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet Uoms { get; set; } public DbSet UomConversions { get; set; } public DbSet Items { get; set; } + public DbSet ItemBatches { get; set; } + public DbSet ItemSerialNumbers { get; set; } public DbSet SaleRoutes { get; set; } public DbSet ShopPOs { get; set; } public DbSet ShopPOItems { get; set; } @@ -47,6 +49,8 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet PurchaseOrderItems { get; set; } public DbSet GoodsReceipts { get; set; } public DbSet GoodsReceiptItems { get; set; } + public DbSet GoodsReceiptItemBatches { get; set; } + public DbSet GoodsReceiptItemSerials { get; set; } public DbSet PurchaseReturns { get; set; } public DbSet PurchaseReturnItems { get; set; } public DbSet SaleReturns { get; set; } @@ -83,8 +87,15 @@ public class AppDBContext(DbContextOptions options) : DbContext(op // --- Unique indexes --- + // NOTE: MySQL treats each NULL as distinct in a unique index, so this does not by itself + // prevent duplicate rows for untracked items (both FK columns null) — StockRepository's + // get-then-update pattern is what keeps those to one row per (Item, Location), same as before. modelBuilder.Entity() - .HasIndex(sb => new { sb.FK_ItemId, sb.LocationId }) + .HasIndex(sb => new { sb.FK_ItemId, sb.LocationId, sb.FK_ItemBatchId, sb.FK_ItemSerialNumberId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(s => new { s.FK_ItemId, s.SerialNumber }) .IsUnique(); modelBuilder.Entity() @@ -316,6 +327,79 @@ public class AppDBContext(DbContextOptions options) : DbContext(op modelBuilder.Entity() .HasIndex(i => i.FK_PurchaseOrderItemId); + // --- ItemBatch / ItemSerialNumber --- + + modelBuilder.Entity() + .HasOne(b => b.Item) + .WithMany() + .HasForeignKey(b => b.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(b => new { b.FK_ItemId, b.BatchNumber }); + + modelBuilder.Entity() + .HasOne(s => s.Item) + .WithMany() + .HasForeignKey(s => s.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + // --- StockBalance / StockMovement batch & serial links --- + + modelBuilder.Entity() + .HasOne(sb => sb.ItemBatch) + .WithMany() + .HasForeignKey(sb => sb.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sb => sb.ItemSerialNumber) + .WithMany() + .HasForeignKey(sb => sb.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sm => sm.ItemBatch) + .WithMany() + .HasForeignKey(sm => sm.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(sm => sm.ItemSerialNumber) + .WithMany() + .HasForeignKey(sm => sm.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + // --- GoodsReceiptItemBatch / GoodsReceiptItemSerial --- + + modelBuilder.Entity() + .HasOne(b => b.GoodsReceiptItem) + .WithMany(i => i.Batches) + .HasForeignKey(b => b.FK_GoodsReceiptItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(b => b.ItemBatch) + .WithMany() + .HasForeignKey(b => b.FK_ItemBatchId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.GoodsReceiptItem) + .WithMany(i => i.Serials) + .HasForeignKey(s => s.FK_GoodsReceiptItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.ItemSerialNumber) + .WithMany() + .HasForeignKey(s => s.FK_ItemSerialNumberId) + .OnDelete(DeleteBehavior.Restrict); + // --- PurchaseReturn --- modelBuilder.Entity() @@ -478,7 +562,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasMaxLength(100); modelBuilder.Entity().HasData( - new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null } + new SysConfig { Key = "InvoiceCreationMode", Value = "ModeB", UpdatedAt = new DateTime(2026, 9, 17), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeySerialNumberTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyBatchTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyExpiryDateTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyWarrantyTrackingEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyCustomWarrantyOverrideEnabled, Value = "false", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }, + new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null } ); Seed.RoleAndPermissionSeed.Apply(modelBuilder); diff --git a/Distribution/Features/Item/ItemDTOs.cs b/Distribution/Features/Item/ItemDTOs.cs index 977980c..d973780 100644 --- a/Distribution/Features/Item/ItemDTOs.cs +++ b/Distribution/Features/Item/ItemDTOs.cs @@ -12,7 +12,13 @@ public record CreateItemRequest( Guid BaseUomId, decimal? SalePrice, List? Conversions, - decimal? ReorderLevel = null); + decimal? ReorderLevel = null, + bool TracksSerialNumbers = false, + bool TracksBatches = false, + bool TracksExpiryDate = false, + bool HasWarranty = false, + int? WarrantyPeriodDays = null, + bool AllowCustomWarrantyPeriod = false); public record UpdateItemRequest( string Name, @@ -23,7 +29,13 @@ public record UpdateItemRequest( Guid BaseUomId, decimal? SalePrice, bool IsActive = true, - decimal? ReorderLevel = null); + decimal? ReorderLevel = null, + bool TracksSerialNumbers = false, + bool TracksBatches = false, + bool TracksExpiryDate = false, + bool HasWarranty = false, + int? WarrantyPeriodDays = null, + bool AllowCustomWarrantyPeriod = false); public record ReplaceConversionsRequest(List Conversions); @@ -43,6 +55,12 @@ public record ItemSummaryResponse( string BaseUomName, decimal? SalePrice, decimal? ReorderLevel, + bool TracksSerialNumbers, + bool TracksBatches, + bool TracksExpiryDate, + bool HasWarranty, + int? WarrantyPeriodDays, + bool AllowCustomWarrantyPeriod, Models.SyncSource SyncSource, bool IsActive, DateTime CreatedAt); @@ -62,6 +80,12 @@ public record ItemDetailResponse( string BaseUomName, decimal? SalePrice, decimal? ReorderLevel, + bool TracksSerialNumbers, + bool TracksBatches, + bool TracksExpiryDate, + bool HasWarranty, + int? WarrantyPeriodDays, + bool AllowCustomWarrantyPeriod, Models.SyncSource SyncSource, bool IsActive, DateTime CreatedAt, diff --git a/Distribution/Features/Item/ItemService.cs b/Distribution/Features/Item/ItemService.cs index 82b76a0..a796fda 100644 --- a/Distribution/Features/Item/ItemService.cs +++ b/Distribution/Features/Item/ItemService.cs @@ -2,7 +2,7 @@ using Distribution.Infra.UoW; namespace Distribution.Features.Item; -public class ItemService(IItemRepository repo, IUnitOfWork uow) +public class ItemService(IItemRepository repo, IUnitOfWork uow, SysConfig.SysConfigService sysConfig) { private static UomConversionResponse MapConversion(Models.UomConversion c) => new(c.Id, c.FK_FromUomId, c.FromUom.Name, c.FK_ToUomId, c.ToUom.Name, c.Factor); @@ -13,7 +13,10 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) i.FK_SubCategoryId, i.SubCategory?.Name, i.FK_BrandId, i.Brand?.Name, i.FK_BaseUomId, i.BaseUom.Name, - i.SalePrice, i.ReorderLevel, i.SyncSource, i.IsActive, i.CreatedAt); + i.SalePrice, i.ReorderLevel, + i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate, + i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod, + i.SyncSource, i.IsActive, i.CreatedAt); private static ItemDetailResponse MapDetail(Models.Item i) => new(i.Id, i.Sku, i.Name, i.Description, @@ -21,9 +24,44 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) i.FK_SubCategoryId, i.SubCategory?.Name, i.FK_BrandId, i.Brand?.Name, i.FK_BaseUomId, i.BaseUom.Name, - i.SalePrice, i.ReorderLevel, i.SyncSource, i.IsActive, i.CreatedAt, + i.SalePrice, i.ReorderLevel, + i.TracksSerialNumbers, i.TracksBatches, i.TracksExpiryDate, + i.HasWarranty, i.WarrantyPeriodDays, i.AllowCustomWarrantyPeriod, + i.SyncSource, i.IsActive, i.CreatedAt, i.Conversions.Select(MapConversion).ToList()); + /// + /// Rejects per-item tracking flags that are off system-wide, and warranty-period values that + /// don't make sense given HasWarranty/AllowCustomWarrantyPeriod. + /// + private async Task ValidateTrackingFlagsAsync(bool tracksSerialNumbers, bool tracksBatches, + bool tracksExpiryDate, bool hasWarranty, int? warrantyPeriodDays, bool allowCustomWarrantyPeriod) + { + if (tracksSerialNumbers && !await sysConfig.GetSerialNumberTrackingEnabledAsync()) + throw new InvalidOperationException("Serial number tracking is disabled system-wide."); + + if (tracksBatches && !await sysConfig.GetBatchTrackingEnabledAsync()) + throw new InvalidOperationException("Batch tracking is disabled system-wide."); + + if (tracksExpiryDate && !await sysConfig.GetExpiryDateTrackingEnabledAsync()) + throw new InvalidOperationException("Expiry date tracking is disabled system-wide."); + + if (hasWarranty && !await sysConfig.GetWarrantyTrackingEnabledAsync()) + throw new InvalidOperationException("Warranty tracking is disabled system-wide."); + + if (allowCustomWarrantyPeriod && !await sysConfig.GetCustomWarrantyOverrideEnabledAsync()) + throw new InvalidOperationException("Custom warranty period override is disabled system-wide."); + + if (!hasWarranty && (warrantyPeriodDays is not null || allowCustomWarrantyPeriod)) + throw new InvalidOperationException("Warranty period/override can only be set when HasWarranty is true."); + + if (warrantyPeriodDays is not null && !allowCustomWarrantyPeriod) + throw new InvalidOperationException("WarrantyPeriodDays can only be set when AllowCustomWarrantyPeriod is true."); + + if (warrantyPeriodDays is < 0) + throw new InvalidOperationException("WarrantyPeriodDays cannot be negative."); + } + public async Task> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) => (await repo.GetAllAsync(categoryId, subCategoryId, brandId)) .Select(MapSummary) @@ -41,6 +79,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) if (await repo.SkuExistsAsync(request.Sku)) throw new InvalidOperationException($"SKU '{request.Sku}' already exists."); + await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches, + request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod); + var item = new Models.Item { Sku = request.Sku, @@ -52,6 +93,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) FK_BaseUomId = request.BaseUomId, SalePrice = request.SalePrice, ReorderLevel = request.ReorderLevel, + TracksSerialNumbers = request.TracksSerialNumbers, + TracksBatches = request.TracksBatches, + TracksExpiryDate = request.TracksExpiryDate, + HasWarranty = request.HasWarranty, + WarrantyPeriodDays = request.WarrantyPeriodDays, + AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod, SyncSource = Models.SyncSource.Manual }; @@ -78,6 +125,9 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) if (item is null) return null; if (item.SyncSource == Models.SyncSource.Erp) return null; + await ValidateTrackingFlagsAsync(request.TracksSerialNumbers, request.TracksBatches, + request.TracksExpiryDate, request.HasWarranty, request.WarrantyPeriodDays, request.AllowCustomWarrantyPeriod); + item.Name = request.Name; item.Description = request.Description; item.FK_CategoryId = request.CategoryId; @@ -86,6 +136,12 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow) item.FK_BaseUomId = request.BaseUomId; item.SalePrice = request.SalePrice; item.ReorderLevel = request.ReorderLevel; + item.TracksSerialNumbers = request.TracksSerialNumbers; + item.TracksBatches = request.TracksBatches; + item.TracksExpiryDate = request.TracksExpiryDate; + item.HasWarranty = request.HasWarranty; + item.WarrantyPeriodDays = request.WarrantyPeriodDays; + item.AllowCustomWarrantyPeriod = request.AllowCustomWarrantyPeriod; item.IsActive = request.IsActive; await uow.BeginAsync(ct); diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 2fbc6bf..f23dfe2 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -75,6 +75,7 @@ builder.Services.AddScoped( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -109,6 +110,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -- 2.52.0 From d28d996295d868e01a64928d935cd0553de4e233 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:06:22 +0530 Subject: [PATCH 02/16] Add product configurations --- .../Features/SysConfig/SysConfigController.cs | 5 ++- .../Features/SysConfig/SysConfigDTOs.cs | 2 +- .../Features/SysConfig/SysConfigService.cs | 40 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Distribution/Features/SysConfig/SysConfigController.cs b/Distribution/Features/SysConfig/SysConfigController.cs index a2571bc..bfacd85 100644 --- a/Distribution/Features/SysConfig/SysConfigController.cs +++ b/Distribution/Features/SysConfig/SysConfigController.cs @@ -42,10 +42,11 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c [HttpPut("{key}")] public async Task Set(string key, SetSysConfigRequest request, CancellationToken ct) { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + var userId = currentUser.UserId ?? request.UserId; + if (userId is null) return BadRequest(new { error = "UserId is required while auth is unavailable" }); try { - var result = await service.SetAsync(key, request.Value, userId, ct); + var result = await service.SetAsync(key, request.Value, userId.Value, ct); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } diff --git a/Distribution/Features/SysConfig/SysConfigDTOs.cs b/Distribution/Features/SysConfig/SysConfigDTOs.cs index 6084673..2907521 100644 --- a/Distribution/Features/SysConfig/SysConfigDTOs.cs +++ b/Distribution/Features/SysConfig/SysConfigDTOs.cs @@ -4,6 +4,6 @@ namespace Distribution.Features.SysConfig; public record SysConfigResponse(string Key, string Value, DateTime UpdatedAt, Guid? UpdatedBy); -public record SetSysConfigRequest(string Value); +public record SetSysConfigRequest(string Value, Guid? UserId); public record SetInvoiceCreationModeRequest(InvoiceCreationMode Mode); diff --git a/Distribution/Features/SysConfig/SysConfigService.cs b/Distribution/Features/SysConfig/SysConfigService.cs index 397d53c..a47bca1 100644 --- a/Distribution/Features/SysConfig/SysConfigService.cs +++ b/Distribution/Features/SysConfig/SysConfigService.cs @@ -7,6 +7,22 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow) { public const string KeyInvoiceCreationMode = "InvoiceCreationMode"; + public const string KeySerialNumberTrackingEnabled = "Feature.SerialNumberTracking"; + public const string KeyBatchTrackingEnabled = "Feature.BatchTracking"; + public const string KeyExpiryDateTrackingEnabled = "Feature.ExpiryDateTracking"; + public const string KeyWarrantyTrackingEnabled = "Feature.WarrantyTracking"; + public const string KeyCustomWarrantyOverrideEnabled = "Feature.CustomWarrantyOverride"; + public const string KeyDefaultWarrantyPeriodDays = "Feature.DefaultWarrantyPeriodDays"; + + private static readonly string[] BooleanFeatureKeys = + [ + KeySerialNumberTrackingEnabled, + KeyBatchTrackingEnabled, + KeyExpiryDateTrackingEnabled, + KeyWarrantyTrackingEnabled, + KeyCustomWarrantyOverrideEnabled, + ]; + public async Task> GetAllAsync() { var configs = await repo.GetAllAsync(); @@ -55,11 +71,35 @@ public class SysConfigService(ISysConfigRepository repo, IUnitOfWork uow) return Enum.TryParse(config.Value, out var mode) ? mode : InvoiceCreationMode.ModeB; } + public async Task GetSerialNumberTrackingEnabledAsync() => await GetBoolAsync(KeySerialNumberTrackingEnabled); + public async Task GetBatchTrackingEnabledAsync() => await GetBoolAsync(KeyBatchTrackingEnabled); + public async Task GetExpiryDateTrackingEnabledAsync() => await GetBoolAsync(KeyExpiryDateTrackingEnabled); + public async Task GetWarrantyTrackingEnabledAsync() => await GetBoolAsync(KeyWarrantyTrackingEnabled); + public async Task GetCustomWarrantyOverrideEnabledAsync() => await GetBoolAsync(KeyCustomWarrantyOverrideEnabled); + + public async Task GetDefaultWarrantyPeriodDaysAsync() + { + var config = await repo.GetByKeyAsync(KeyDefaultWarrantyPeriodDays); + return config is not null && int.TryParse(config.Value, out var days) ? days : 0; + } + + private async Task GetBoolAsync(string key) + { + var config = await repo.GetByKeyAsync(key); + return config is not null && bool.TryParse(config.Value, out var enabled) && enabled; + } + private static void ValidateValue(string key, string value) { if (key == KeyInvoiceCreationMode && !Enum.TryParse(value, out _)) throw new InvalidOperationException( $"Invalid value '{value}' for '{key}'. Allowed values: {string.Join(", ", Enum.GetNames())}."); + + if (BooleanFeatureKeys.Contains(key) && !bool.TryParse(value, out _)) + throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Allowed values: true, false."); + + if (key == KeyDefaultWarrantyPeriodDays && (!int.TryParse(value, out var days) || days < 0)) + throw new InvalidOperationException($"Invalid value '{value}' for '{key}'. Must be a non-negative integer."); } private static SysConfigResponse ToResponse(Models.SysConfig c) => -- 2.52.0 From bfd3e5cdedabd8865fa3dcb3061935bea5db67ae Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:07:45 +0530 Subject: [PATCH 03/16] add product configuration UI --- .../items/_components/item-sheet.tsx | 163 +++++++++++- .../items/_components/item-view-sheet.tsx | 2 +- .../items/_components/items-client.tsx | 18 +- .../configuration/products/page.tsx | 234 ++++++++++++++++++ 4 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/configuration/products/page.tsx diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx index 7dba95e..b0908c5 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx @@ -23,7 +23,7 @@ import { SelectValue, } from '@/components/ui/select' import { toast } from '@/components/ui/toast' -import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, getApiError } from '@/lib/api/index' +import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api' const schema = z.object({ @@ -33,11 +33,33 @@ const schema = z.object({ brandId: z.string().optional(), baseUomId: z.string().min(1, 'Base UOM is required'), salePrice: z.number().min(0, 'Sale price must be 0 or more'), + tracksSerialNumbers: z.boolean(), + tracksBatches: z.boolean(), + tracksExpiryDate: z.boolean(), + hasWarranty: z.boolean(), + warrantyPeriodDays: z.number().min(0).optional(), + allowCustomWarrantyPeriod: z.boolean(), isActive: z.boolean(), }) type FormValues = z.infer +interface TrackingToggles { + serialNumberTracking: boolean + batchTracking: boolean + expiryDateTracking: boolean + warrantyTracking: boolean + customWarrantyOverride: boolean +} + +const NO_TRACKING_ENABLED: TrackingToggles = { + serialNumberTracking: false, + batchTracking: false, + expiryDateTracking: false, + warrantyTracking: false, + customWarrantyOverride: false, +} + interface ItemSheetProps { open: boolean onClose: () => void @@ -51,6 +73,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { const [categories, setCategories] = useState([]) const [brandsList, setBrandsList] = useState([]) const [uomsList, setUomsList] = useState([]) + const [tracking, setTracking] = useState(NO_TRACKING_ENABLED) const form = useForm({ resolver: zodResolver(schema), @@ -61,6 +84,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { brandId: undefined, baseUomId: '', salePrice: 0, + tracksSerialNumbers: false, + tracksBatches: false, + tracksExpiryDate: false, + hasWarranty: false, + warrantyPeriodDays: undefined, + allowCustomWarrantyPeriod: false, isActive: true, }, }) @@ -71,10 +100,19 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { categoriesApi.list(), brandsApi.list(), uomsApi.list(), - ]).then(([cats, brandsRes, uomsRes]) => { + sysConfig.getAll(), + ]).then(([cats, brandsRes, uomsRes, configs]) => { setCategories(cats) setBrandsList(brandsRes) setUomsList(uomsRes) + const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) + setTracking({ + serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', + batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', + expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', + warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', + customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', + }) }).catch((err) => { toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) }) @@ -88,6 +126,12 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { brandId: item.brandId ?? undefined, baseUomId: item.baseUomId, salePrice: item.salePrice, + tracksSerialNumbers: item.tracksSerialNumbers, + tracksBatches: item.tracksBatches, + tracksExpiryDate: item.tracksExpiryDate, + hasWarranty: item.hasWarranty, + warrantyPeriodDays: item.warrantyPeriodDays ?? undefined, + allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod, isActive: item.isActive, } : { @@ -97,24 +141,33 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { brandId: undefined, baseUomId: '', salePrice: 0, + tracksSerialNumbers: false, + tracksBatches: false, + tracksExpiryDate: false, + hasWarranty: false, + warrantyPeriodDays: undefined, + allowCustomWarrantyPeriod: false, isActive: true, } ) } }, [open, item]) + const hasWarranty = form.watch('hasWarranty') + async function onSubmit(values: FormValues) { try { const body = { ...values, brandId: values.brandId || null, + warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null, } if (isEdit) { await itemsApi.update(item!.id, body) - toast.add({ title: 'Item updated', type: 'success' }) + toast.add({ title: 'Product updated', type: 'success' }) } else { await itemsApi.create(body) - toast.add({ title: 'Item created', type: 'success' }) + toast.add({ title: 'Product created', type: 'success' }) } onSuccess() onClose() @@ -127,9 +180,9 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { !v && onClose()}> - {isEdit ? 'Edit Item' : 'Add Item'} + {isEdit ? 'Edit Product' : 'Add Product'} - {isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'} + {isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'} @@ -157,7 +210,7 @@ export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { ( + {(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && ( +
+ Tracking + + {tracking.serialNumberTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.batchTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.expiryDateTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.warrantyTracking && ( + <> + ( +
+ + +
+ )} + /> + + {hasWarranty && tracking.customWarrantyOverride && ( + <> + ( +
+ + +
+ )} + /> + + {form.watch('allowCustomWarrantyPeriod') && ( + ( + field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)} + onBlur={field.onBlur} + placeholder="System default" + /> + )} + /> + )} + + )} + + )} +
+ )} + diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx index 1381063..27a1fa0 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx @@ -34,7 +34,7 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) { !v && onClose()}> - Item Details + Product Details
diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx index 607a43c..9d11f7c 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx @@ -34,7 +34,7 @@ export function ItemsClient() { setData(getPaginatedItems(res)) setTotal(res.total) } catch (err) { - toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }) + toast.add({ title: 'Failed to load products', description: getApiError(err), type: 'error' }) } finally { setLoading(false) } @@ -62,10 +62,10 @@ export function ItemsClient() { async function handleDelete(item: ItemSummaryResponse) { try { await itemsApi.remove(item.id) - toast.add({ title: 'Item deactivated', type: 'success' }) + toast.add({ title: 'Product deactivated', type: 'success' }) fetchData(page, pageSize) } catch (err) { - toast.add({ title: 'Failed to deactivate item', description: getApiError(err), type: 'error' }) + toast.add({ title: 'Failed to deactivate product', description: getApiError(err), type: 'error' }) } } @@ -73,7 +73,7 @@ export function ItemsClient() { rowKey: 'id', serverSide: true, stickyHeader: true, - emptyMessage: 'No items yet. Add one to get started.', + emptyMessage: 'No products yet. Add one to get started.', pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, columns: [ { @@ -137,17 +137,17 @@ export function ItemsClient() { actions: () => [ { action: 'view', - label: 'View item', + label: 'View product', onClick: openView, }, { action: 'edit', - label: 'Edit item', + label: 'Edit product', onClick: openEdit, }, { action: 'cancel', - label: 'Delete item', + label: 'Delete product', onClick: handleDelete, visible: (target) => target.isActive, }, @@ -159,14 +159,14 @@ export function ItemsClient() { <>
-

Items

+

Products

Products available for sale and distribution

diff --git a/distribution_fe/app/(dashboard)/configuration/products/page.tsx b/distribution_fe/app/(dashboard)/configuration/products/page.tsx new file mode 100644 index 0000000..8fdb336 --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/products/page.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon } from '@hugeicons/core-free-icons' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { FieldLabel, FieldDescription } from '@/components/ui/field' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' +import { cn } from '@/lib/utils' +import { useAuth } from '@/contexts/auth-context' + +interface FormState { + serialNumberTracking: boolean + batchTracking: boolean + expiryDateTracking: boolean + warrantyTracking: boolean + customWarrantyOverride: boolean + defaultWarrantyPeriodDays: number +} + +const DEFAULTS: FormState = { + serialNumberTracking: false, + batchTracking: false, + expiryDateTracking: false, + warrantyTracking: false, + customWarrantyOverride: false, + defaultWarrantyPeriodDays: 0, +} + +interface CapabilityToggleProps { + id: string + label: string + description: string + checked: boolean + disabled?: boolean + onCheckedChange: (checked: boolean) => void + className?: string +} + +function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) { + return ( +
+
+ + {label} + + {description} +
+
+ {checked ? 'ON' : 'OFF'} + onCheckedChange(!!v)} /> +
+
+ ) +} + +export default function ProductsConfigurationPage() { + const { user } = useAuth() + const [loading, setLoading] = useState(true) + const [form, setForm] = useState(DEFAULTS) + const [pendingKeys, setPendingKeys] = useState>({}) + + useEffect(() => { + sysConfig.getAll() + .then((configs) => { + const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) + setForm({ + serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', + batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', + expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', + warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', + customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', + defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0), + }) + }) + .catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' })) + .finally(() => setLoading(false)) + }, []) + + async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) { + const previous = form[formKey] + setForm(f => ({ ...f, [formKey]: value })) + setPendingKeys(p => ({ ...p, [configKey]: true })) + try { + await sysConfig.set(configKey, String(value), user.id) + } catch (err) { + setForm(f => ({ ...f, [formKey]: previous })) + toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) + } finally { + setPendingKeys(p => ({ ...p, [configKey]: false })) + } + } + + async function updateDefaultWarrantyDays(value: number) { + const previous = form.defaultWarrantyPeriodDays + setForm(f => ({ ...f, defaultWarrantyPeriodDays: value })) + if (value === previous) return + setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true })) + try { + await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id) + } catch (err) { + setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous })) + toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) + } finally { + setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false })) + } + } + + return ( +
+
+ + + Configuration + +
+

Product Configuration

+

+ Control which tracking capabilities are available to products across the catalogue. +

+
+
+ + {loading ? ( +

Loading...

+ ) : ( + + + Product Capabilities + + Each switch applies immediately. Items can then opt into the enabled capabilities from the item's edit form. + + + +
+ updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} + /> + + updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} + /> + + updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} + /> + + updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} + /> +
+ + {form.warrantyTracking && ( + <> + + +
+

+ Warranty defaults +

+ +
+ updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} + /> + +
+ + Default warranty period + +
+ setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))} + onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)} + className="max-w-24" + /> + days +
+ + Applied to items that don't set their own warranty period. + +
+
+
+ + )} +
+
+ )} +
+ ) +} -- 2.52.0 From 9707ef596c8308efbd9f197bc44bf285d5782124 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:17:32 +0530 Subject: [PATCH 04/16] UI changes --- .../app/(dashboard)/configuration/page.tsx | 54 +++++++++++++++++++ .../operations/loading/new/page.tsx | 33 +++++++++--- .../components/layout/app-sidebar.tsx | 20 ++++++- 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/configuration/page.tsx diff --git a/distribution_fe/app/(dashboard)/configuration/page.tsx b/distribution_fe/app/(dashboard)/configuration/page.tsx new file mode 100644 index 0000000..e515190 --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import Link from 'next/link' +import { HugeiconsIcon } from '@hugeicons/react' +import { Package01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons' +import { Card, CardHeader, CardTitle, CardDescription, CardAction } from '@/components/ui/card' + +interface ConfigCategory { + title: string + description: string + href: string + icon: typeof Package01Icon +} + +const CATEGORIES: ConfigCategory[] = [ + { + title: 'Products', + description: 'Serial number, batch, expiry and warranty tracking for catalogue items', + href: '/configuration/products', + icon: Package01Icon, + }, +] + +export default function configurationPage() { + return ( +
+
+

Configuration

+

System-wide settings, grouped by area

+
+ +
+ {CATEGORIES.map((category) => ( + + + +
+
+ +
+ {category.title} +
+ {category.description} + + + +
+
+ + ))} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx b/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx index 64fb407..07fe7db 100644 --- a/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx +++ b/distribution_fe/app/(dashboard)/operations/loading/new/page.tsx @@ -71,7 +71,7 @@ const STEPS = [ n: 3, label: 'Items', title: 'What should be loaded from Shop POs?', - help: "Expand a shop to tick items and set quantities. If the main warehouse doesn't have enough stock, use \"Take remaining from another warehouse\" to top up the rest.", + help: "Expand a shop to tick items and set quantities. If the main warehouse can't cover an item, a top-up row is added automatically — just pick which other warehouse to take the rest from.", }, { n: 4, @@ -321,10 +321,16 @@ function LoadingSheetForm() { let changed = false const next: Record = {} for (const [key, l] of Object.entries(prev)) { - const extras = l.extras.filter((e) => e.warehouseId !== warehouseId) + let extras = l.extras.filter((e) => e.warehouseId !== warehouseId) const avail = stockByWh[warehouseId]?.[l.itemId] ?? 0 const quantity = Math.min(l.quantity, avail) const checked = l.checked && (quantity > 0 || extras.length > 0) + // Pre-add an empty top-up row if the new main warehouse leaves this line short + // and one isn't already pending, mirroring toggleLine's auto-suggestion. + const shortBy = l.remainingQty - quantity - extras.reduce((sum, e) => sum + (Number(e.quantity) || 0), 0) + if (checked && shortBy > 0 && !extras.some((e) => !e.warehouseId)) { + extras = [...extras, { warehouseId: '', quantity: 0 }] + } if (extras.length !== l.extras.length || quantity !== l.quantity || checked !== l.checked) changed = true next[key] = { ...l, extras, quantity, checked } } @@ -452,7 +458,12 @@ function LoadingSheetForm() { const avail = availIn(warehouseId, line.itemId) if (!stockLoaded || avail <= 0) return const quantity = Math.max(0, Math.min(line.remainingQty - extrasTotal(line), avail)) - setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked, quantity } })) + // The main warehouse can't cover the full remaining quantity, so a top-up row is + // pre-added (still editable/removable) instead of making the user click "Take + // remaining from another warehouse" themselves. + const shortBy = line.remainingQty - quantity - extrasTotal(line) + const extras = shortBy > 0 ? [...line.extras, { warehouseId: '', quantity: 0 }] : line.extras + setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked, quantity, extras } })) return } setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } })) @@ -912,10 +923,18 @@ function LoadingSheetForm() { - {shortBy} will stay open on the PO for another sheet - + {line.extras.some((e) => !e.warehouseId) ? ( + + {shortBy} short — pick a warehouse above to top it up, or it stays open on the PO for another sheet. + + ) : ( + <> + {shortBy} will stay open on the PO for another sheet + + + )} )} diff --git a/distribution_fe/components/layout/app-sidebar.tsx b/distribution_fe/components/layout/app-sidebar.tsx index 22883c0..d3786e3 100644 --- a/distribution_fe/components/layout/app-sidebar.tsx +++ b/distribution_fe/components/layout/app-sidebar.tsx @@ -38,7 +38,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { useAuth, type Role } from '@/contexts/auth-context' -import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav' +import { DASHBOARD_ITEM, CONFIGURATION_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav' function canSee(item: NavItem, permissions: string[]): boolean { if (!item.permission) return true @@ -54,6 +54,8 @@ export function AppSidebar() { const pathname = usePathname() const dashboardActive = pathname === DASHBOARD_ITEM.href + const configurationActive = + pathname === CONFIGURATION_ITEM.href || pathname.startsWith(CONFIGURATION_ITEM.href + '/') const [openGroups, setOpenGroups] = useState>({}) useEffect(() => { @@ -182,6 +184,22 @@ export function AppSidebar() { ) })} + + {/* Configuration — standalone, no sub-nav */} + + + + } + isActive={configurationActive} + tooltip={CONFIGURATION_ITEM.title} + > + + {CONFIGURATION_ITEM.title} + + + + -- 2.52.0 From e1db9b1b6901fbc91f5392e79cf923918b0aac8e Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 13:19:02 +0530 Subject: [PATCH 05/16] add shared card and toggle components --- distribution_fe/components/ui/card.tsx | 99 ++++++++++++++++++++++++ distribution_fe/components/ui/switch.tsx | 31 ++++++++ 2 files changed, 130 insertions(+) create mode 100644 distribution_fe/components/ui/card.tsx create mode 100644 distribution_fe/components/ui/switch.tsx diff --git a/distribution_fe/components/ui/card.tsx b/distribution_fe/components/ui/card.tsx new file mode 100644 index 0000000..3a62fd8 --- /dev/null +++ b/distribution_fe/components/ui/card.tsx @@ -0,0 +1,99 @@ +import * as React from "react" +import { cn } from "cn" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/distribution_fe/components/ui/switch.tsx b/distribution_fe/components/ui/switch.tsx new file mode 100644 index 0000000..3424d16 --- /dev/null +++ b/distribution_fe/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client" + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" +import { cn } from "cn" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } -- 2.52.0 From d578cbad20793ebc170748bc60fe23e9e7c4f365 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 2 Oct 2026 14:18:39 +0530 Subject: [PATCH 06/16] fix routing --- distribution_fe/config/nav.ts | 12 ++++++++++-- distribution_fe/lib/api/catalogue.ts | 7 +++++++ distribution_fe/lib/api/index.ts | 1 + distribution_fe/lib/api/sys-config.ts | 24 ++++++++++++++++++++++++ distribution_fe/types/api.ts | 7 +++++++ 5 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 distribution_fe/lib/api/sys-config.ts diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 0ab7029..a89b4dc 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -26,6 +26,7 @@ import { ClipboardListIcon, BankIcon, PackageMoving01Icon, + Settings01Icon, } from '@hugeicons/core-free-icons' export interface NavItem { @@ -49,6 +50,13 @@ export const DASHBOARD_ITEM: NavItem = { icon: DashboardSquare01Icon, } +// Standalone item rendered at the bottom — links straight to the page, no sub-nav +export const CONFIGURATION_ITEM: NavItem = { + title: 'Configuration', + href: '/configuration', + icon: Settings01Icon, +} + export const NAV_GROUPS: NavGroup[] = [ { title: 'Shop POs & Invoices', @@ -80,7 +88,7 @@ export const NAV_GROUPS: NavGroup[] = [ title: 'Catalogue', icon: CatalogueIcon, items: [ - { title: 'Items', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' }, + { title: 'Products', href: '/catalogue/items', icon: Package01Icon, description: 'Products available for sale and distribution', permission: 'items.view' }, { title: 'Categories', href: '/catalogue/categories', icon: CatalogueIcon, description: 'Product groupings and sub-categories', permission: 'categories.view' }, { title: 'Brands', href: '/catalogue/brands', icon: BarCode01Icon, description: 'Brands associated with catalogue items', permission: 'categories.view' }, // { title: 'UOMs', href: '/catalogue/uoms', icon: Layers01Icon, description: 'Units of measure and conversion factors between units', permission: 'categories.view' }, @@ -162,5 +170,5 @@ export const BOTTOM_NAV_CANDIDATES: NavItem[] = [ { title: 'Rides', href: '/operations/rides', icon: TruckIcon, permission: 'rides.view' }, { title: 'Loading', href: '/operations/loading', icon: DeliveryBox01Icon, permission: 'loading.view' }, { title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, permission: 'orders.view' }, - { title: 'Items', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' }, + { title: 'Products', href: '/catalogue/items', icon: Package01Icon, permission: 'items.view' }, ] diff --git a/distribution_fe/lib/api/catalogue.ts b/distribution_fe/lib/api/catalogue.ts index b2a598b..5f4e54e 100644 --- a/distribution_fe/lib/api/catalogue.ts +++ b/distribution_fe/lib/api/catalogue.ts @@ -28,6 +28,13 @@ export interface ItemBody { brandId?: string | null baseUomId: string salePrice: number + reorderLevel?: number | null + tracksSerialNumbers?: boolean + tracksBatches?: boolean + tracksExpiryDate?: boolean + hasWarranty?: boolean + warrantyPeriodDays?: number | null + allowCustomWarrantyPeriod?: boolean syncSource?: SyncSource isActive?: boolean } diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 65c81eb..90173a0 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -10,6 +10,7 @@ export { rides, loadingSheets, deliveryTasks } from './operations' export { stock } from './stock' export { shopPos, invoices, payments } from './sales' export { saleReturns, claims, supplierClaims } from './returns-claims' +export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config' export { getPaginatedItems } from './paginated' export type { PageParams } from './master' diff --git a/distribution_fe/lib/api/sys-config.ts b/distribution_fe/lib/api/sys-config.ts new file mode 100644 index 0000000..d68b953 --- /dev/null +++ b/distribution_fe/lib/api/sys-config.ts @@ -0,0 +1,24 @@ +import { api } from '@/lib/api' +import type { SysConfigResponse } from '@/types/api' + +// ─── Item tracking feature keys (must match Distribution.Features.SysConfig.SysConfigService) ── + +export const ITEM_TRACKING_KEYS = { + serialNumberTracking: 'Feature.SerialNumberTracking', + batchTracking: 'Feature.BatchTracking', + expiryDateTracking: 'Feature.ExpiryDateTracking', + warrantyTracking: 'Feature.WarrantyTracking', + customWarrantyOverride: 'Feature.CustomWarrantyOverride', + defaultWarrantyPeriodDays: 'Feature.DefaultWarrantyPeriodDays', +} as const + +export const sysConfig = { + getAll: () => + api.get('/sys-config').then(r => r.data), + + get: (key: string) => + api.get(`/sys-config/${key}`).then(r => r.data), + + set: (key: string, value: string, userId?: string | null) => + api.put(`/sys-config/${key}`, { value, userId }).then(r => r.data), +} diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 9527713..77206d0 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -282,6 +282,13 @@ export interface ItemSummaryResponse { baseUomId: string baseUomName: string salePrice: number + reorderLevel: number | null + tracksSerialNumbers: boolean + tracksBatches: boolean + tracksExpiryDate: boolean + hasWarranty: boolean + warrantyPeriodDays: number | null + allowCustomWarrantyPeriod: boolean syncSource: SyncSource isActive: boolean createdAt: string -- 2.52.0 From a8725bc473155bb5b4b02e51344bc1dfa8f68f45 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 6 Oct 2026 13:26:17 +0530 Subject: [PATCH 07/16] Add GRN batch and serial tracking --- Distribution/Data/AppDBContext.cs | 14 + Distribution/Features/Grn/GrnController.cs | 14 + Distribution/Features/Grn/GrnDTOs.cs | 35 +- Distribution/Features/Grn/GrnRepository.cs | 22 +- Distribution/Features/Grn/GrnService.cs | 331 ++++++++++++++++-- Distribution/Models/GoodsReceipt.cs | 10 +- Distribution/Models/GoodsReceiptItem.cs | 16 +- Distribution/Models/GoodsReceiptItemBatch.cs | 18 + Distribution/Models/GoodsReceiptItemSerial.cs | 16 + 9 files changed, 427 insertions(+), 49 deletions(-) create mode 100644 Distribution/Models/GoodsReceiptItemBatch.cs create mode 100644 Distribution/Models/GoodsReceiptItemSerial.cs diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 44b39fb..26ba8f7 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -303,6 +303,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(gr => gr.FK_PurchaseOrderId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(gr => gr.Supplier) + .WithMany() + .HasForeignKey(gr => gr.FK_SupplierId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + modelBuilder.Entity() .HasOne(gr => gr.Warehouse) .WithMany() @@ -321,6 +328,13 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(i => i.FK_PurchaseOrderItemId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.Item) + .WithMany() + .HasForeignKey(i => i.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + modelBuilder.Entity() .HasIndex(i => i.FK_GoodsReceiptId); diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs index f87f56a..7f43379 100644 --- a/Distribution/Features/Grn/GrnController.cs +++ b/Distribution/Features/Grn/GrnController.cs @@ -39,6 +39,20 @@ public class GrnController(GrnService service, ICurrentUserService currentUser) catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } + /// Goods received without a purchase order. + [HttpPost("api/goods-receipts/direct")] + public async Task CreateDirect(CreateDirectGrnRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? request.ReceivedByUserId; + if (userId is null) return BadRequest(new { error = "ReceivedByUserId is required while auth is unavailable" }); + try + { + var result = await service.CreateDirectAsync(request, userId.Value, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + [HttpGet("api/purchase-orders/{id:guid}/goods-receipts")] public async Task GetByPurchaseOrder(Guid id) => Ok(await service.GetByPurchaseOrderIdAsync(id)); diff --git a/Distribution/Features/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs index b5d8a02..d4b9692 100644 --- a/Distribution/Features/Grn/GrnDTOs.cs +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -1,6 +1,19 @@ namespace Distribution.Features.Grn; -public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity); +/// BatchNumber is null for an expiry-only lot (TracksExpiryDate without TracksBatches). +public record GrnBatchLineRequest(string? BatchNumber, DateTime? ExpiryDate, int Quantity); + +public record GrnItemRequest( + // Set for a PO-based receipt; null for a direct receipt (ItemId is used instead). + Guid? PurchaseOrderItemId, + int AcceptedQuantity, + int RejectedQuantity, + // Populated only when the item has TracksBatches and/or TracksExpiryDate set. + List? Batches = null, + // Populated only when the item has TracksSerialNumbers set — must contain exactly AcceptedQuantity entries. + List? SerialNumbers = null, + // Set for a direct receipt line (no purchase order). + Guid? ItemId = null); public record CreateGrnRequest( Guid WarehouseId, @@ -15,21 +28,35 @@ public record CreateFlatGrnRequest( // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. Guid? ReceivedByUserId = null); +/// A goods receipt with no purchase order. SupplierId is optional. +public record CreateDirectGrnRequest( + Guid WarehouseId, + List Items, + Guid? SupplierId = null, + // TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28. + Guid? ReceivedByUserId = null); + +public record GrnBatchLineResponse(string? BatchNumber, DateTime? ExpiryDate, int Quantity); + public record GrnItemResponse( Guid Id, - Guid PurchaseOrderItemId, + Guid? PurchaseOrderItemId, Guid ItemId, string Sku, string ItemName, int AcceptedQuantity, - int RejectedQuantity); + int RejectedQuantity, + List Batches, + List SerialNumbers); public record GrnResponse( Guid Id, - Guid PurchaseOrderId, + Guid? PurchaseOrderId, Guid ReceivedByUserId, Guid WarehouseId, string WarehouseName, + Guid? SupplierId, + string? SupplierName, DateTime ReceivedAt, DateTime CreatedAt, List Items); diff --git a/Distribution/Features/Grn/GrnRepository.cs b/Distribution/Features/Grn/GrnRepository.cs index 22cc657..01181f7 100644 --- a/Distribution/Features/Grn/GrnRepository.cs +++ b/Distribution/Features/Grn/GrnRepository.cs @@ -8,13 +8,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository public async Task GetByIdAsync(Guid id) => await context.GoodsReceipts .Include(gr => gr.Warehouse) - .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .FirstOrDefaultAsync(gr => gr.Id == id); public async Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) => await context.GoodsReceipts .Include(gr => gr.Warehouse) - .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId)) .OrderByDescending(gr => gr.ReceivedAt) .Skip((page - 1) * pageSize) @@ -29,7 +37,11 @@ public class GrnRepository(AppDBContext context) : IGrnRepository public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => await context.GoodsReceipts .Include(gr => gr.Warehouse) - .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Include(gr => gr.Supplier) + .Include(gr => gr.Items).ThenInclude(i => i.Item) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi!.Item) + .Include(gr => gr.Items).ThenInclude(i => i.Batches).ThenInclude(b => b.ItemBatch) + .Include(gr => gr.Items).ThenInclude(i => i.Serials).ThenInclude(s => s.ItemSerialNumber) .Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive) .OrderByDescending(gr => gr.ReceivedAt) .ToListAsync(); @@ -43,8 +55,8 @@ public class GrnRepository(AppDBContext context) : IGrnRepository public async Task> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) => await context.GoodsReceiptItems .Include(i => i.PurchaseOrderItem) - .Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) - .GroupBy(i => i.PurchaseOrderItem.FK_ItemId) + .Where(i => i.PurchaseOrderItem!.FK_PurchaseOrderId == purchaseOrderId && i.IsActive) + .GroupBy(i => i.PurchaseOrderItem!.FK_ItemId) .Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) }) .ToDictionaryAsync(x => x.ItemId, x => x.Total); } diff --git a/Distribution/Features/Grn/GrnService.cs b/Distribution/Features/Grn/GrnService.cs index 10bae5f..dd5df45 100644 --- a/Distribution/Features/Grn/GrnService.cs +++ b/Distribution/Features/Grn/GrnService.cs @@ -9,14 +9,23 @@ public class GrnService( IGrnRepository repo, IPoRepository poRepo, IStockRepository stockRepo, - IUnitOfWork uow) + IUnitOfWork uow, + Distribution.Features.Item.IItemRepository itemRepo, + Distribution.Features.Supplier.ISupplierRepository supplierRepo) { - private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) => - new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku, - i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity); + private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) + { + // Direct-receipt lines carry the item themselves; PO lines resolve it through the PO line. + var item = i.Item ?? i.PurchaseOrderItem!.Item; + return new(i.Id, i.FK_PurchaseOrderItemId, item.Id, item.Sku, item.Name, + i.AcceptedQuantity, i.RejectedQuantity, + i.Batches.Select(b => new GrnBatchLineResponse(b.ItemBatch.BatchNumber, b.ItemBatch.ExpiryDate, b.Quantity)).ToList(), + i.Serials.Select(s => s.ItemSerialNumber.SerialNumber).ToList()); + } private static GrnResponse Map(Models.GoodsReceipt gr) => new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty, + gr.FK_SupplierId, gr.Supplier?.Name, gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList()); public async Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) @@ -36,10 +45,60 @@ public class GrnService( public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); + /// + /// Validates that a GRN line's batch/serial payload matches the item's tracking flags. When an + /// item tracks both serial numbers and batches/expiry, serial tracking takes precedence for + /// stock-ledger granularity in this phase — batch/expiry metadata for that combination is not + /// yet linked into the ledger (follow-up work). + /// + private static void ValidateLineShape(Models.Item item, GrnItemRequest line) + { + if (item.TracksSerialNumbers) + { + if (line.AcceptedQuantity > 0 && (line.SerialNumbers is null || line.SerialNumbers.Count != line.AcceptedQuantity)) + throw new InvalidOperationException( + $"Item {item.Sku} tracks serial numbers — expected {line.AcceptedQuantity} serial number(s)."); + + if (line.SerialNumbers is { Count: > 0 } && line.SerialNumbers.Distinct().Count() != line.SerialNumbers.Count) + throw new InvalidOperationException($"Duplicate serial numbers supplied for item {item.Sku}."); + } + else if (line.SerialNumbers is { Count: > 0 }) + { + throw new InvalidOperationException($"Item {item.Sku} does not track serial numbers."); + } + else if (item.TracksBatches || item.TracksExpiryDate) + { + if (line.AcceptedQuantity > 0) + { + if (line.Batches is null || line.Batches.Count == 0) + throw new InvalidOperationException($"Item {item.Sku} requires batch/expiry information."); + if (line.Batches.Sum(b => b.Quantity) != line.AcceptedQuantity) + throw new InvalidOperationException($"Batch quantities for item {item.Sku} must sum to the accepted quantity."); + } + + foreach (var b in line.Batches ?? []) + { + if (b.Quantity <= 0) + throw new InvalidOperationException($"Batch quantity for item {item.Sku} must be positive."); + if (item.TracksBatches && string.IsNullOrWhiteSpace(b.BatchNumber)) + throw new InvalidOperationException($"Item {item.Sku} requires a batch number."); + if (!item.TracksBatches && !string.IsNullOrWhiteSpace(b.BatchNumber)) + throw new InvalidOperationException($"Item {item.Sku} does not track batch numbers."); + if (item.TracksExpiryDate && b.ExpiryDate is null) + throw new InvalidOperationException($"Item {item.Sku} requires an expiry date."); + } + } + else if (line.Batches is { Count: > 0 }) + { + throw new InvalidOperationException($"Item {item.Sku} does not track batches or expiry dates."); + } + } + /// /// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/ - /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and - /// transitions the Po to PartiallyReceived/Received. All in one unit of work. + /// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), creates + /// ItemBatch/ItemSerialNumber records for tracked items, and transitions the Po to + /// PartiallyReceived/Received. All in one unit of work. /// public async Task CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) { @@ -64,8 +123,13 @@ public class GrnService( ReceivedAt = DateTime.Now }; + var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); + foreach (var line in request.Items) { + if (line.PurchaseOrderItemId is null) + throw new InvalidOperationException("Each line of a PO receipt needs a PurchaseOrderItemId."); + var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive) ?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po."); @@ -79,14 +143,27 @@ public class GrnService( throw new InvalidOperationException( $"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity."); + ValidateLineShape(poItem.Item, line); + + if (poItem.Item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 }) + { + foreach (var serialNumber in line.SerialNumbers) + { + if (await stockRepo.SerialNumberExistsAsync(poItem.FK_ItemId, serialNumber)) + throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {poItem.Item.Sku}."); + } + } + poItem.ReceivedQuantity += line.AcceptedQuantity; - goodsReceipt.Items.Add(new Models.GoodsReceiptItem + var entity = new Models.GoodsReceiptItem { FK_PurchaseOrderItemId = poItem.Id, AcceptedQuantity = line.AcceptedQuantity, RejectedQuantity = line.RejectedQuantity - }); + }; + goodsReceipt.Items.Add(entity); + grLines.Add((entity, line, poItem.Item)); } var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList(); @@ -94,48 +171,230 @@ public class GrnService( ? PurchaseOrderStatus.Received : PurchaseOrderStatus.PartiallyReceived; + return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, po, ct); + } + /// + /// A receipt with no purchase order: lines name the item directly. Stock, serial and batch + /// handling is identical to a PO receipt; there is no PO quantity or status to update. + /// + public async Task CreateDirectAsync(CreateDirectGrnRequest request, Guid receivedByUserId, CancellationToken ct = default) + { + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A goods receipt must have at least one line item."); + + if (request.SupplierId is not null && await supplierRepo.GetByIdAsync(request.SupplierId.Value) is not { IsActive: true }) + throw new InvalidOperationException("Supplier not found."); + + var goodsReceipt = new Models.GoodsReceipt + { + FK_PurchaseOrderId = null, + FK_SupplierId = request.SupplierId, + FK_ReceivedByUserId = receivedByUserId, + FK_WarehouseId = request.WarehouseId, + ReceivedAt = DateTime.Now + }; + + var grLines = new List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)>(); + var serialsInRequest = new HashSet<(Guid ItemId, string Serial)>(); + + foreach (var line in request.Items) + { + if (line.ItemId is null) + throw new InvalidOperationException("Each line of a direct receipt needs an ItemId."); + + var item = await itemRepo.GetByIdAsync(line.ItemId.Value); + if (item is null || !item.IsActive) + throw new InvalidOperationException($"Item {line.ItemId} not found."); + + if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0) + throw new InvalidOperationException("Accepted/rejected quantities cannot be negative."); + + if (line.AcceptedQuantity + line.RejectedQuantity == 0) + throw new InvalidOperationException($"Line for item {item.Sku} has no quantity."); + + ValidateLineShape(item, line); + + if (item.TracksSerialNumbers && line.SerialNumbers is { Count: > 0 }) + { + foreach (var serialNumber in line.SerialNumbers) + { + if (!serialsInRequest.Add((item.Id, serialNumber))) + throw new InvalidOperationException($"Serial number '{serialNumber}' appears on more than one line for item {item.Sku}."); + if (await stockRepo.SerialNumberExistsAsync(item.Id, serialNumber)) + throw new InvalidOperationException($"Serial number '{serialNumber}' already exists for item {item.Sku}."); + } + } + + var entity = new Models.GoodsReceiptItem + { + FK_ItemId = item.Id, + AcceptedQuantity = line.AcceptedQuantity, + RejectedQuantity = line.RejectedQuantity + }; + goodsReceipt.Items.Add(entity); + grLines.Add((entity, line, item)); + } + + return await PostReceiptAsync(goodsReceipt, grLines, request.WarehouseId, receivedByUserId, null, ct); + } + + /// + /// Writes the receipt, its serials/batches, the stock balances and movements, and (for a PO + /// receipt) the PO update in one unit of work. Shared by PO-based and direct receipts. + /// + private async Task PostReceiptAsync( + Models.GoodsReceipt goodsReceipt, + List<(Models.GoodsReceiptItem Entity, GrnItemRequest Line, Models.Item Item)> grLines, + Guid warehouseId, + Guid receivedByUserId, + Models.PurchaseOrder? po, + CancellationToken ct) + { await uow.BeginAsync(ct); try { await repo.AddAsync(goodsReceipt); - foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0)) + foreach (var (entity, line, item) in grLines.Where(x => x.Line.AcceptedQuantity > 0)) { - var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId); - - var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId); - if (balance is null) + if (item.TracksSerialNumbers) { - await stockRepo.AddBalanceAsync(new StockBalance + foreach (var serialNumber in line.SerialNumbers!) { - FK_ItemId = poItem.FK_ItemId, - LocationId = request.WarehouseId, - LocationType = LocationType.Warehouse, - Quantity = line.AcceptedQuantity - }); + var serial = new ItemSerialNumber + { + FK_ItemId = item.Id, + SerialNumber = serialNumber, + Status = SerialStatus.InStock, + CurrentLocationId = warehouseId, + CurrentLocationType = LocationType.Warehouse, + ReceivedAt = goodsReceipt.ReceivedAt + }; + + // Warranty is a customer-facing promise: the clock starts when the serial is sold, + // not at receipt. Only snapshot the item's custom period here; WarrantyExpiresAt + // stays null until the sale/delivery flow sets it. + if (item.HasWarranty && item.AllowCustomWarrantyPeriod) + serial.WarrantyPeriodDaysOverride = item.WarrantyPeriodDays; + + await stockRepo.AddItemSerialNumberAsync(serial); + entity.Serials.Add(new GoodsReceiptItemSerial { FK_GoodsReceiptItemId = entity.Id, ItemSerialNumber = serial }); + + await stockRepo.AddBalanceAsync(new StockBalance + { + FK_ItemId = item.Id, + LocationId = warehouseId, + LocationType = LocationType.Warehouse, + FK_ItemSerialNumberId = serial.Id, + Quantity = 1 + }); + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = item.Id, + FromLocationId = StockService.ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = warehouseId, + ToLocationType = LocationType.Warehouse, + FK_ItemSerialNumberId = serial.Id, + Quantity = 1, + Type = MovementType.PurchaseReceipt, + ReferenceId = goodsReceipt.Id, + CreatedBy = receivedByUserId + }); + } + } + else if (item.TracksBatches || item.TracksExpiryDate) + { + foreach (var b in line.Batches!) + { + var batch = !string.IsNullOrWhiteSpace(b.BatchNumber) + ? await stockRepo.FindBatchAsync(item.Id, b.BatchNumber) + : null; + + if (batch is null) + { + batch = new ItemBatch + { + FK_ItemId = item.Id, + BatchNumber = b.BatchNumber, + ExpiryDate = b.ExpiryDate + }; + await stockRepo.AddItemBatchAsync(batch); + } + + entity.Batches.Add(new GoodsReceiptItemBatch { FK_GoodsReceiptItemId = entity.Id, ItemBatch = batch, Quantity = b.Quantity }); + + var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId, batch.Id, null); + if (balance is null) + { + await stockRepo.AddBalanceAsync(new StockBalance + { + FK_ItemId = item.Id, + LocationId = warehouseId, + LocationType = LocationType.Warehouse, + FK_ItemBatchId = batch.Id, + Quantity = b.Quantity + }); + } + else + { + balance.Quantity += b.Quantity; + balance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(balance); + } + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = item.Id, + FromLocationId = StockService.ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = warehouseId, + ToLocationType = LocationType.Warehouse, + FK_ItemBatchId = batch.Id, + Quantity = b.Quantity, + Type = MovementType.PurchaseReceipt, + ReferenceId = goodsReceipt.Id, + CreatedBy = receivedByUserId + }); + } } else { - balance.Quantity += line.AcceptedQuantity; - balance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(balance); - } + var balance = await stockRepo.GetBalanceAsync(item.Id, warehouseId); + if (balance is null) + { + await stockRepo.AddBalanceAsync(new StockBalance + { + FK_ItemId = item.Id, + LocationId = warehouseId, + LocationType = LocationType.Warehouse, + Quantity = line.AcceptedQuantity + }); + } + else + { + balance.Quantity += line.AcceptedQuantity; + balance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(balance); + } - await stockRepo.AddMovementAsync(new StockMovement - { - FK_ItemId = poItem.FK_ItemId, - FromLocationId = StockService.ExternalLocationId, - FromLocationType = LocationType.External, - ToLocationId = request.WarehouseId, - ToLocationType = LocationType.Warehouse, - Quantity = line.AcceptedQuantity, - Type = MovementType.PurchaseReceipt, - ReferenceId = goodsReceipt.Id, - CreatedBy = receivedByUserId - }); + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = item.Id, + FromLocationId = StockService.ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = warehouseId, + ToLocationType = LocationType.Warehouse, + Quantity = line.AcceptedQuantity, + Type = MovementType.PurchaseReceipt, + ReferenceId = goodsReceipt.Id, + CreatedBy = receivedByUserId + }); + } } - poRepo.Update(po); + if (po is not null) poRepo.Update(po); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } diff --git a/Distribution/Models/GoodsReceipt.cs b/Distribution/Models/GoodsReceipt.cs index 4ce1368..cee1549 100644 --- a/Distribution/Models/GoodsReceipt.cs +++ b/Distribution/Models/GoodsReceipt.cs @@ -4,9 +4,15 @@ namespace Distribution.Models { public class GoodsReceipt : Template { - public Guid FK_PurchaseOrderId { get; set; } + /// Null for a direct receipt (goods received without a purchase order). + public Guid? FK_PurchaseOrderId { get; set; } [ForeignKey("FK_PurchaseOrderId")] - public PurchaseOrder PurchaseOrder { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + /// Optional supplier for a direct receipt; for PO receipts the supplier comes from the PO. + public Guid? FK_SupplierId { get; set; } + [ForeignKey("FK_SupplierId")] + public Supplier? Supplier { get; set; } public Guid FK_ReceivedByUserId { get; set; } diff --git a/Distribution/Models/GoodsReceiptItem.cs b/Distribution/Models/GoodsReceiptItem.cs index 7a04ebb..b905f69 100644 --- a/Distribution/Models/GoodsReceiptItem.cs +++ b/Distribution/Models/GoodsReceiptItem.cs @@ -8,14 +8,26 @@ namespace Distribution.Models [ForeignKey("FK_GoodsReceiptId")] public GoodsReceipt GoodsReceipt { get; set; } - public Guid FK_PurchaseOrderItemId { get; set; } + /// Null for a direct receipt line (no purchase order); FK_ItemId is set instead. + public Guid? FK_PurchaseOrderItemId { get; set; } [ForeignKey("FK_PurchaseOrderItemId")] - public PurchaseOrderItem PurchaseOrderItem { get; set; } + public PurchaseOrderItem? PurchaseOrderItem { get; set; } + + /// Set for direct receipt lines. PO-based lines resolve the item through PurchaseOrderItem. + public Guid? FK_ItemId { get; set; } + [ForeignKey("FK_ItemId")] + public Item? Item { get; set; } /// Added to warehouse stock. public int AcceptedQuantity { get; set; } /// Damaged/short at receipt, not added to stock. public int RejectedQuantity { get; set; } = 0; + + /// Populated when the received item tracks batches and/or expiry dates. + public ICollection Batches { get; set; } = []; + + /// Populated when the received item tracks serial numbers. + public ICollection Serials { get; set; } = []; } } diff --git a/Distribution/Models/GoodsReceiptItemBatch.cs b/Distribution/Models/GoodsReceiptItemBatch.cs new file mode 100644 index 0000000..2d0ae9d --- /dev/null +++ b/Distribution/Models/GoodsReceiptItemBatch.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + /// One row per batch/expiry-lot split within a single GRN line. + public class GoodsReceiptItemBatch : Template + { + public Guid FK_GoodsReceiptItemId { get; set; } + [ForeignKey("FK_GoodsReceiptItemId")] + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public Guid FK_ItemBatchId { get; set; } + [ForeignKey("FK_ItemBatchId")] + public ItemBatch ItemBatch { get; set; } + + public int Quantity { get; set; } + } +} diff --git a/Distribution/Models/GoodsReceiptItemSerial.cs b/Distribution/Models/GoodsReceiptItemSerial.cs new file mode 100644 index 0000000..62d7180 --- /dev/null +++ b/Distribution/Models/GoodsReceiptItemSerial.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + /// One row per serial number received within a single GRN line. + public class GoodsReceiptItemSerial : Template + { + public Guid FK_GoodsReceiptItemId { get; set; } + [ForeignKey("FK_GoodsReceiptItemId")] + public GoodsReceiptItem GoodsReceiptItem { get; set; } + + public Guid FK_ItemSerialNumberId { get; set; } + [ForeignKey("FK_ItemSerialNumberId")] + public ItemSerialNumber ItemSerialNumber { get; set; } + } +} -- 2.52.0 From 92f1aadb818787356b2c40fb70dd5b02607e9ca0 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Tue, 6 Oct 2026 13:28:37 +0530 Subject: [PATCH 08/16] Add item and GRN tracking forms --- .../catalogue/items/_components/item-form.tsx | 406 +++++++++++++++++ .../items/_components/item-sheet.tsx | 423 +----------------- .../items/_components/item-view-sheet.tsx | 21 + .../items/_components/items-client.tsx | 23 +- .../(dashboard)/catalogue/items/new/page.tsx | 32 ++ .../_components/product-capabilities-card.tsx | 220 +++++++++ .../app/(dashboard)/configuration/page.tsx | 44 +- .../configuration/products/page.tsx | 234 ---------- .../grn/_components/grn-client.tsx | 30 +- .../grn/_components/grn-tracking.tsx | 324 ++++++++++++++ .../grn/_components/grn-view-sheet.tsx | 28 +- .../procurement/grn/direct/new/page.tsx | 316 +++++++++++++ .../grn-sheet.tsx => po/new/page.tsx} | 153 +++++-- distribution_fe/config/nav.ts | 2 +- distribution_fe/lib/api/procurement.ts | 28 +- distribution_fe/types/api.ts | 16 +- 16 files changed, 1536 insertions(+), 764 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx create mode 100644 distribution_fe/app/(dashboard)/catalogue/items/new/page.tsx create mode 100644 distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx delete mode 100644 distribution_fe/app/(dashboard)/configuration/products/page.tsx create mode 100644 distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx create mode 100644 distribution_fe/app/(dashboard)/procurement/grn/direct/new/page.tsx rename distribution_fe/app/(dashboard)/procurement/grn/{_components/grn-sheet.tsx => po/new/page.tsx} (70%) diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx new file mode 100644 index 0000000..77122b0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-form.tsx @@ -0,0 +1,406 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { FieldGroup } from '@/components/ui/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' +import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api' + +const schema = z.object({ + sku: z.string().min(1, 'SKU is required'), + name: z.string().min(1, 'Name is required'), + categoryId: z.string().min(1, 'Category is required'), + brandId: z.string().optional(), + baseUomId: z.string().min(1, 'Base UOM is required'), + salePrice: z.number().min(0, 'Sale price must be 0 or more'), + tracksSerialNumbers: z.boolean(), + tracksBatches: z.boolean(), + tracksExpiryDate: z.boolean(), + hasWarranty: z.boolean(), + warrantyPeriodDays: z.number().min(0).optional(), + allowCustomWarrantyPeriod: z.boolean(), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface TrackingToggles { + serialNumberTracking: boolean + batchTracking: boolean + expiryDateTracking: boolean + warrantyTracking: boolean + customWarrantyOverride: boolean +} + +const NO_TRACKING_ENABLED: TrackingToggles = { + serialNumberTracking: false, + batchTracking: false, + expiryDateTracking: false, + warrantyTracking: false, + customWarrantyOverride: false, +} + +const EMPTY_VALUES: FormValues = { + sku: '', + name: '', + categoryId: '', + brandId: undefined, + baseUomId: '', + salePrice: 0, + tracksSerialNumbers: false, + tracksBatches: false, + tracksExpiryDate: false, + hasWarranty: false, + warrantyPeriodDays: undefined, + allowCustomWarrantyPeriod: false, + isActive: true, +} + +interface ItemFormProps { + item: ItemSummaryResponse | null + onSuccess: () => void + onCancel: () => void + twoColumn?: boolean +} + +export function ItemForm({ item, onSuccess, onCancel, twoColumn = false }: ItemFormProps) { + const isEdit = !!item + + const [categories, setCategories] = useState([]) + const [brandsList, setBrandsList] = useState([]) + const [uomsList, setUomsList] = useState([]) + const [tracking, setTracking] = useState(NO_TRACKING_ENABLED) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: EMPTY_VALUES, + }) + + useEffect(() => { + Promise.all([ + categoriesApi.list(), + brandsApi.list(), + uomsApi.list(), + sysConfig.getAll(), + ]).then(([cats, brandsRes, uomsRes, configs]) => { + setCategories(cats) + setBrandsList(brandsRes) + setUomsList(uomsRes) + const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) + setTracking({ + serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', + batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', + expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', + warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', + customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', + }) + }).catch((err) => { + toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) + }) + + form.reset( + item + ? { + sku: item.sku, + name: item.name, + categoryId: item.categoryId, + brandId: item.brandId ?? undefined, + baseUomId: item.baseUomId, + salePrice: item.salePrice, + tracksSerialNumbers: item.tracksSerialNumbers, + tracksBatches: item.tracksBatches, + tracksExpiryDate: item.tracksExpiryDate, + hasWarranty: item.hasWarranty, + warrantyPeriodDays: item.warrantyPeriodDays ?? undefined, + allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod, + isActive: item.isActive, + } + : EMPTY_VALUES + ) + }, [item]) + + const hasWarranty = form.watch('hasWarranty') + + async function onSubmit(values: FormValues) { + try { + const body = { + ...values, + brandId: values.brandId || null, + warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null, + } + if (isEdit) { + await itemsApi.update(item!.id, body) + toast.add({ title: 'Product updated', type: 'success' }) + } else { + await itemsApi.create(body) + toast.add({ title: 'Product created', type: 'success' }) + } + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + + {(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && ( +
+ Tracking + + {tracking.serialNumberTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.batchTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.expiryDateTracking && ( + ( +
+ + +
+ )} + /> + )} + + {tracking.warrantyTracking && ( + <> + ( +
+ + +
+ )} + /> + + {hasWarranty && tracking.customWarrantyOverride && ( + <> + ( +
+ + +
+ )} + /> + + {form.watch('allowCustomWarrantyPeriod') && ( + ( + field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)} + onBlur={field.onBlur} + placeholder="System default" + /> + )} + /> + )} + + )} + + )} +
+ )} + +
+ ( +
+ + +
+ )} + /> +
+
+ +
+ + +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx index b0908c5..17511e4 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx @@ -1,437 +1,40 @@ 'use client' -import { useEffect, useState } from 'react' -import { useForm, zodResolver, z } from '@/lib/form' -import { FormField } from '@/components/shared/form-field' -import { Input } from '@/components/ui/input' -import { Checkbox } from '@/components/ui/checkbox' -import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, - DialogFooter, } from '@/components/ui/dialog' -import { FieldGroup } from '@/components/ui/field' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { toast } from '@/components/ui/toast' -import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' -import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api' +import type { ItemSummaryResponse } from '@/types/api' -const schema = z.object({ - sku: z.string().min(1, 'SKU is required'), - name: z.string().min(1, 'Name is required'), - categoryId: z.string().min(1, 'Category is required'), - brandId: z.string().optional(), - baseUomId: z.string().min(1, 'Base UOM is required'), - salePrice: z.number().min(0, 'Sale price must be 0 or more'), - tracksSerialNumbers: z.boolean(), - tracksBatches: z.boolean(), - tracksExpiryDate: z.boolean(), - hasWarranty: z.boolean(), - warrantyPeriodDays: z.number().min(0).optional(), - allowCustomWarrantyPeriod: z.boolean(), - isActive: z.boolean(), -}) - -type FormValues = z.infer - -interface TrackingToggles { - serialNumberTracking: boolean - batchTracking: boolean - expiryDateTracking: boolean - warrantyTracking: boolean - customWarrantyOverride: boolean -} - -const NO_TRACKING_ENABLED: TrackingToggles = { - serialNumberTracking: false, - batchTracking: false, - expiryDateTracking: false, - warrantyTracking: false, - customWarrantyOverride: false, -} +import { ItemForm } from './item-form' interface ItemSheetProps { open: boolean onClose: () => void - item: ItemSummaryResponse | null + item: ItemSummaryResponse onSuccess: () => void } export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { - const isEdit = !!item - - const [categories, setCategories] = useState([]) - const [brandsList, setBrandsList] = useState([]) - const [uomsList, setUomsList] = useState([]) - const [tracking, setTracking] = useState(NO_TRACKING_ENABLED) - - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: { - sku: '', - name: '', - categoryId: '', - brandId: undefined, - baseUomId: '', - salePrice: 0, - tracksSerialNumbers: false, - tracksBatches: false, - tracksExpiryDate: false, - hasWarranty: false, - warrantyPeriodDays: undefined, - allowCustomWarrantyPeriod: false, - isActive: true, - }, - }) - - useEffect(() => { - if (open) { - Promise.all([ - categoriesApi.list(), - brandsApi.list(), - uomsApi.list(), - sysConfig.getAll(), - ]).then(([cats, brandsRes, uomsRes, configs]) => { - setCategories(cats) - setBrandsList(brandsRes) - setUomsList(uomsRes) - const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) - setTracking({ - serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', - batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', - expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', - warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', - customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', - }) - }).catch((err) => { - toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) - }) - - form.reset( - item - ? { - sku: item.sku, - name: item.name, - categoryId: item.categoryId, - brandId: item.brandId ?? undefined, - baseUomId: item.baseUomId, - salePrice: item.salePrice, - tracksSerialNumbers: item.tracksSerialNumbers, - tracksBatches: item.tracksBatches, - tracksExpiryDate: item.tracksExpiryDate, - hasWarranty: item.hasWarranty, - warrantyPeriodDays: item.warrantyPeriodDays ?? undefined, - allowCustomWarrantyPeriod: item.allowCustomWarrantyPeriod, - isActive: item.isActive, - } - : { - sku: '', - name: '', - categoryId: '', - brandId: undefined, - baseUomId: '', - salePrice: 0, - tracksSerialNumbers: false, - tracksBatches: false, - tracksExpiryDate: false, - hasWarranty: false, - warrantyPeriodDays: undefined, - allowCustomWarrantyPeriod: false, - isActive: true, - } - ) - } - }, [open, item]) - - const hasWarranty = form.watch('hasWarranty') - - async function onSubmit(values: FormValues) { - try { - const body = { - ...values, - brandId: values.brandId || null, - warrantyPeriodDays: values.hasWarranty && values.allowCustomWarrantyPeriod ? values.warrantyPeriodDays ?? null : null, - } - if (isEdit) { - await itemsApi.update(item!.id, body) - toast.add({ title: 'Product updated', type: 'success' }) - } else { - await itemsApi.create(body) - toast.add({ title: 'Product created', type: 'success' }) - } - onSuccess() - onClose() - } catch (err) { - toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) - } - } - return ( !v && onClose()}> - {isEdit ? 'Edit Product' : 'Add Product'} - - {isEdit ? 'Update the product details below.' : 'Add a new catalogue product.'} - + Edit Product + Update the product details below. -
- - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - field.onChange(parseFloat(e.target.value) || 0)} - onBlur={field.onBlur} - placeholder="0.00" - aria-invalid={invalid} - /> - )} - /> - - {(tracking.serialNumberTracking || tracking.batchTracking || tracking.expiryDateTracking || tracking.warrantyTracking) && ( -
- Tracking - - {tracking.serialNumberTracking && ( - ( -
- - -
- )} - /> - )} - - {tracking.batchTracking && ( - ( -
- - -
- )} - /> - )} - - {tracking.expiryDateTracking && ( - ( -
- - -
- )} - /> - )} - - {tracking.warrantyTracking && ( - <> - ( -
- - -
- )} - /> - - {hasWarranty && tracking.customWarrantyOverride && ( - <> - ( -
- - -
- )} - /> - - {form.watch('allowCustomWarrantyPeriod') && ( - ( - field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value, 10) || 0)} - onBlur={field.onBlur} - placeholder="System default" - /> - )} - /> - )} - - )} - - )} -
- )} - - ( -
- - -
- )} - /> -
- - - - - -
+ { + onSuccess() + onClose() + }} + onCancel={onClose} + />
) diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx index 27a1fa0..9e047b8 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-view-sheet.tsx @@ -44,6 +44,27 @@ export function ItemViewSheet({ open, onClose, item }: ItemViewSheetProps) { + + {item.tracksSerialNumbers && Serial} + {item.tracksBatches && Batch} + {item.tracksExpiryDate && Expiry} + {item.hasWarranty && ( + + Warranty {item.allowCustomWarrantyPeriod && item.warrantyPeriodDays != null + ? `${item.warrantyPeriodDays}d` + : '(default period)'} + + )} + + ) : ( + '—' + ) + } + /> {item.isActive ? 'Active' : 'Inactive'}} /> diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx index 9d11f7c..1f5a72f 100644 --- a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' @@ -16,6 +17,7 @@ import { ItemSheet } from './item-sheet' import { ItemViewSheet } from './item-view-sheet' export function ItemsClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -44,11 +46,6 @@ export function ItemsClient() { fetchData(page, pageSize) }, [fetchData, page, pageSize]) - function openCreate() { - setEditing(null) - setSheetOpen(true) - } - function openEdit(item: ItemSummaryResponse) { setEditing(item) setSheetOpen(true) @@ -164,7 +161,7 @@ export function ItemsClient() { Products available for sale and distribution

- @@ -181,12 +178,14 @@ export function ItemsClient() { }} /> - setSheetOpen(false)} - item={editing} - onSuccess={() => fetchData(page, pageSize)} - /> + {editing && ( + setSheetOpen(false)} + item={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + )} router.push('/catalogue/items') + + return ( +
+
+ +
+

Add Product

+

Add a new catalogue product.

+
+
+ +
+ +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx new file mode 100644 index 0000000..85e4a3c --- /dev/null +++ b/distribution_fe/app/(dashboard)/configuration/_components/product-capabilities-card.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useEffect, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Package01Icon } from '@hugeicons/core-free-icons' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { FieldLabel, FieldDescription } from '@/components/ui/field' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' +import { cn } from '@/lib/utils' +import { useAuth } from '@/contexts/auth-context' + +interface FormState { + serialNumberTracking: boolean + batchTracking: boolean + expiryDateTracking: boolean + warrantyTracking: boolean + customWarrantyOverride: boolean + defaultWarrantyPeriodDays: number +} + +const DEFAULTS: FormState = { + serialNumberTracking: false, + batchTracking: false, + expiryDateTracking: false, + warrantyTracking: false, + customWarrantyOverride: false, + defaultWarrantyPeriodDays: 0, +} + +interface CapabilityToggleProps { + id: string + label: string + description: string + checked: boolean + disabled?: boolean + onCheckedChange: (checked: boolean) => void + className?: string +} + +function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) { + return ( +
+
+ + {label} + + {description} +
+
+ {checked ? 'ON' : 'OFF'} + onCheckedChange(!!v)} /> +
+
+ ) +} + +/** The Products configuration card: tracking capability switches and warranty defaults, saved immediately. */ +export function ProductCapabilitiesCard() { + const { user } = useAuth() + const [loading, setLoading] = useState(true) + const [form, setForm] = useState(DEFAULTS) + const [pendingKeys, setPendingKeys] = useState>({}) + + useEffect(() => { + sysConfig.getAll() + .then((configs) => { + const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) + setForm({ + serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', + batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', + expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', + warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', + customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', + defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0), + }) + }) + .catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' })) + .finally(() => setLoading(false)) + }, []) + + async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) { + const previous = form[formKey] + setForm(f => ({ ...f, [formKey]: value })) + setPendingKeys(p => ({ ...p, [configKey]: true })) + try { + await sysConfig.set(configKey, String(value), user.id) + } catch (err) { + setForm(f => ({ ...f, [formKey]: previous })) + toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) + } finally { + setPendingKeys(p => ({ ...p, [configKey]: false })) + } + } + + async function updateDefaultWarrantyDays(value: number) { + const previous = form.defaultWarrantyPeriodDays + setForm(f => ({ ...f, defaultWarrantyPeriodDays: value })) + if (value === previous) return + setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true })) + try { + await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id) + } catch (err) { + setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous })) + toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) + } finally { + setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false })) + } + } + + return loading ? ( +

Loading...

+ ) : ( + + +
+
+ +
+ Products +
+ + Serial number, batch, expiry and warranty tracking for catalogue items. Each switch applies immediately; + items then opt into the enabled capabilities from the item's edit form. + +
+ +
+ updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} + /> + + updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} + /> + + updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} + /> + + updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} + /> +
+ + {form.warrantyTracking && ( + <> + + +
+

+ Warranty defaults +

+ +
+ updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} + /> + +
+ + Default warranty period + +
+ setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))} + onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)} + className="max-w-24" + /> + days +
+ + Applied to items that don't set their own warranty period. + +
+
+
+ + )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/configuration/page.tsx b/distribution_fe/app/(dashboard)/configuration/page.tsx index e515190..b787920 100644 --- a/distribution_fe/app/(dashboard)/configuration/page.tsx +++ b/distribution_fe/app/(dashboard)/configuration/page.tsx @@ -1,27 +1,8 @@ 'use client' -import Link from 'next/link' -import { HugeiconsIcon } from '@hugeicons/react' -import { Package01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons' -import { Card, CardHeader, CardTitle, CardDescription, CardAction } from '@/components/ui/card' +import { ProductCapabilitiesCard } from './_components/product-capabilities-card' -interface ConfigCategory { - title: string - description: string - href: string - icon: typeof Package01Icon -} - -const CATEGORIES: ConfigCategory[] = [ - { - title: 'Products', - description: 'Serial number, batch, expiry and warranty tracking for catalogue items', - href: '/configuration/products', - icon: Package01Icon, - }, -] - -export default function configurationPage() { +export default function ConfigurationPage() { return (
@@ -29,26 +10,7 @@ export default function configurationPage() {

System-wide settings, grouped by area

-
- {CATEGORIES.map((category) => ( - - - -
-
- -
- {category.title} -
- {category.description} - - - -
-
- - ))} -
+
) } diff --git a/distribution_fe/app/(dashboard)/configuration/products/page.tsx b/distribution_fe/app/(dashboard)/configuration/products/page.tsx deleted file mode 100644 index 8fdb336..0000000 --- a/distribution_fe/app/(dashboard)/configuration/products/page.tsx +++ /dev/null @@ -1,234 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import Link from 'next/link' -import { HugeiconsIcon } from '@hugeicons/react' -import { ArrowLeft01Icon } from '@hugeicons/core-free-icons' -import { Switch } from '@/components/ui/switch' -import { Input } from '@/components/ui/input' -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { FieldLabel, FieldDescription } from '@/components/ui/field' -import { Separator } from '@/components/ui/separator' -import { toast } from '@/components/ui/toast' -import { sysConfig, ITEM_TRACKING_KEYS, getApiError } from '@/lib/api/index' -import { cn } from '@/lib/utils' -import { useAuth } from '@/contexts/auth-context' - -interface FormState { - serialNumberTracking: boolean - batchTracking: boolean - expiryDateTracking: boolean - warrantyTracking: boolean - customWarrantyOverride: boolean - defaultWarrantyPeriodDays: number -} - -const DEFAULTS: FormState = { - serialNumberTracking: false, - batchTracking: false, - expiryDateTracking: false, - warrantyTracking: false, - customWarrantyOverride: false, - defaultWarrantyPeriodDays: 0, -} - -interface CapabilityToggleProps { - id: string - label: string - description: string - checked: boolean - disabled?: boolean - onCheckedChange: (checked: boolean) => void - className?: string -} - -function CapabilityToggle({ id, label, description, checked, disabled, onCheckedChange, className }: CapabilityToggleProps) { - return ( -
-
- - {label} - - {description} -
-
- {checked ? 'ON' : 'OFF'} - onCheckedChange(!!v)} /> -
-
- ) -} - -export default function ProductsConfigurationPage() { - const { user } = useAuth() - const [loading, setLoading] = useState(true) - const [form, setForm] = useState(DEFAULTS) - const [pendingKeys, setPendingKeys] = useState>({}) - - useEffect(() => { - sysConfig.getAll() - .then((configs) => { - const byKey = Object.fromEntries(configs.map(c => [c.key, c.value])) - setForm({ - serialNumberTracking: byKey[ITEM_TRACKING_KEYS.serialNumberTracking] === 'true', - batchTracking: byKey[ITEM_TRACKING_KEYS.batchTracking] === 'true', - expiryDateTracking: byKey[ITEM_TRACKING_KEYS.expiryDateTracking] === 'true', - warrantyTracking: byKey[ITEM_TRACKING_KEYS.warrantyTracking] === 'true', - customWarrantyOverride: byKey[ITEM_TRACKING_KEYS.customWarrantyOverride] === 'true', - defaultWarrantyPeriodDays: Number(byKey[ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays] ?? 0), - }) - }) - .catch((err) => toast.add({ title: 'Failed to load configuration', description: getApiError(err), type: 'error' })) - .finally(() => setLoading(false)) - }, []) - - async function updateToggle(formKey: keyof FormState, configKey: string, value: boolean) { - const previous = form[formKey] - setForm(f => ({ ...f, [formKey]: value })) - setPendingKeys(p => ({ ...p, [configKey]: true })) - try { - await sysConfig.set(configKey, String(value), user.id) - } catch (err) { - setForm(f => ({ ...f, [formKey]: previous })) - toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) - } finally { - setPendingKeys(p => ({ ...p, [configKey]: false })) - } - } - - async function updateDefaultWarrantyDays(value: number) { - const previous = form.defaultWarrantyPeriodDays - setForm(f => ({ ...f, defaultWarrantyPeriodDays: value })) - if (value === previous) return - setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: true })) - try { - await sysConfig.set(ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays, String(value), user.id) - } catch (err) { - setForm(f => ({ ...f, defaultWarrantyPeriodDays: previous })) - toast.add({ title: 'Failed to update configuration', description: getApiError(err), type: 'error' }) - } finally { - setPendingKeys(p => ({ ...p, [ITEM_TRACKING_KEYS.defaultWarrantyPeriodDays]: false })) - } - } - - return ( -
-
- - - Configuration - -
-

Product Configuration

-

- Control which tracking capabilities are available to products across the catalogue. -

-
-
- - {loading ? ( -

Loading...

- ) : ( - - - Product Capabilities - - Each switch applies immediately. Items can then opt into the enabled capabilities from the item's edit form. - - - -
- updateToggle('serialNumberTracking', ITEM_TRACKING_KEYS.serialNumberTracking, v)} - /> - - updateToggle('batchTracking', ITEM_TRACKING_KEYS.batchTracking, v)} - /> - - updateToggle('expiryDateTracking', ITEM_TRACKING_KEYS.expiryDateTracking, v)} - /> - - updateToggle('warrantyTracking', ITEM_TRACKING_KEYS.warrantyTracking, v)} - /> -
- - {form.warrantyTracking && ( - <> - - -
-

- Warranty defaults -

- -
- updateToggle('customWarrantyOverride', ITEM_TRACKING_KEYS.customWarrantyOverride, v)} - /> - -
- - Default warranty period - -
- setForm(f => ({ ...f, defaultWarrantyPeriodDays: parseInt(e.target.value, 10) || 0 }))} - onBlur={(e) => updateDefaultWarrantyDays(parseInt(e.target.value, 10) || 0)} - className="max-w-24" - /> - days -
- - Applied to items that don't set their own warranty period. - -
-
-
- - )} -
-
- )} -
- ) -} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx index f3a6a97..ca0259d 100644 --- a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { Add01Icon } from '@hugeicons/core-free-icons' import { DataTable } from '@/components/shared/data-table' @@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/toast' import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { GrnResponse } from '@/types/api' -import { GrnSheet } from './grn-sheet' import { GrnViewSheet } from './grn-view-sheet' const fmtDate = (v: string) => @@ -18,12 +18,12 @@ const fmtDate = (v: string) => const truncId = (id: string) => id.substring(0, 8) export function GrnClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [sheetOpen, setSheetOpen] = useState(false) const [viewing, setViewing] = useState(null) const [viewOpen, setViewOpen] = useState(false) @@ -71,7 +71,9 @@ export function GrnClient() { fields: ['purchaseOrderId'], width: 140, cellRender: (row) => ( - {truncId(row.purchaseOrderId)} + row.purchaseOrderId + ? {truncId(row.purchaseOrderId)} + : Direct receipt ), }, { @@ -119,13 +121,19 @@ export function GrnClient() {

Goods Receipts

- Stock received into a warehouse against a purchase order + Stock received into a warehouse, against a purchase order or directly

- +
+ + +
- setSheetOpen(false)} - onSuccess={() => fetchData(page, pageSize)} - /> - setViewOpen(false)} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx new file mode 100644 index 0000000..2d31ab6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-tracking.tsx @@ -0,0 +1,324 @@ +'use client' + +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/toast' +import type { GrnBatchLineBody } from '@/lib/api/procurement' +import type { ItemSummaryResponse } from '@/types/api' + +export function parseSerials(text: string): string[] { + return text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean) +} + +export type BatchRow = { batchNumber: string; expiryDate: string; quantity: number } + +interface Progress { + kind: 'serial' | 'batch' + done: number + complete: boolean + duplicates: string[] +} + +/** How far a tracked line is from satisfying the server rules; null for untracked items. */ +export function trackingProgress( + info: ItemSummaryResponse | undefined, + accepted: number, + serialText: string, + batches: BatchRow[], +): Progress | null { + if (info?.tracksSerialNumbers) { + const serials = parseSerials(serialText) + const seen = new Set() + const dupes = new Set() + for (const s of serials) { + if (seen.has(s)) dupes.add(s) + seen.add(s) + } + return { + kind: 'serial', + done: serials.length, + duplicates: [...dupes], + complete: accepted === 0 || (serials.length === accepted && dupes.size === 0), + } + } + if (info?.tracksBatches || info?.tracksExpiryDate) { + const done = batches.reduce((s, b) => s + (b.quantity || 0), 0) + const rowsOk = batches.every( + b => + b.quantity > 0 && + (!info.tracksBatches || b.batchNumber.trim() !== '') && + (!info.tracksExpiryDate || b.expiryDate !== ''), + ) + return { kind: 'batch', done, duplicates: [], complete: accepted === 0 || (done === accepted && rowsOk) } + } + return null +} + +interface LineTrackingProps { + info: ItemSummaryResponse + name: string + accepted: number + serialText: string + batches: BatchRow[] + onSerialText: (v: string) => void + onBatches: (v: BatchRow[]) => void +} + +/** + * Compact status chip for one GRN line; clicking it opens a panel for the actual serial/batch + * entry, so long serial lists don't stretch the form. Serial tracking takes precedence over + * batch/expiry (same as the server). Warranty is not entered here — its clock starts at sale. + */ +export function LineTracking({ info, name, accepted, serialText, batches, onSerialText, onBatches }: LineTrackingProps) { + const [panelOpen, setPanelOpen] = useState(false) + const [scanValue, setScanValue] = useState('') + const [genPrefix, setGenPrefix] = useState(`${info.sku}-`) + const [genStart, setGenStart] = useState(1) + const progress = trackingProgress(info, accepted, serialText, batches) + if (!progress) return null + + // Scanner (keyboard-wedge) input: each scan ends with Enter and appends one serial. + const addScanned = () => { + const value = scanValue.trim() + if (!value) return + if (parseSerials(serialText).includes(value)) { + toast.add({ title: `Serial ${value} is already in the list`, type: 'error' }) + } else { + onSerialText(serialText.trim() ? `${serialText.trimEnd()}\n${value}` : value) + } + setScanValue('') + } + + // Appends generated serials (prefix + zero-padded sequence) for the units still missing, + // skipping any value already in the list. They stay editable in the list below. + const generateSerials = () => { + const existing = parseSerials(serialText) + const taken = new Set(existing) + const missing = Math.max(0, accepted - existing.length) + const generated: string[] = [] + for (let n = genStart; generated.length < missing; n++) { + const sn = `${genPrefix}${String(n).padStart(4, '0')}` + if (!taken.has(sn)) generated.push(sn) + } + onSerialText([...existing, ...generated].join('\n')) + setGenStart(genStart + missing) + } + + const isSerial = progress.kind === 'serial' + const noun = isSerial ? 'serials' : info.tracksBatches ? 'batches' : 'expiry lots' + const warrantyNote = info.hasWarranty + ? `Warranty: ${info.warrantyPeriodDays != null && info.allowCustomWarrantyPeriod ? `${info.warrantyPeriodDays} days` : 'system default period'}, starts at sale.` + : null + + const now = new Date() + const today = now.toISOString().slice(0, 10) + const inThirtyDays = new Date(now) + inThirtyDays.setDate(inThirtyDays.getDate() + 30) + const soon = inThirtyDays.toISOString().slice(0, 10) + const update = (i: number, patch: Partial) => + onBatches(batches.map((b, j) => (j === i ? { ...b, ...patch } : b))) + + return ( +
+ + {warrantyNote && {warrantyNote}} + + + + + {isSerial ? 'Serial numbers' : info.tracksBatches ? 'Batches' : 'Expiry lots'} + + {name} ({info.sku}) — accepting {accepted} + + + +
+ {isSerial ? ( + <> + setScanValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + addScanned() + } + }} + /> +
+ setGenPrefix(e.target.value)} + /> + setGenStart(Math.max(1, Number(e.target.value)))} + /> + +
+