From 263e2ea9c063446da3aaede71e48500de4ff9907 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:34:25 +0530 Subject: [PATCH 1/5] Refactor Delivery and Purchase Return services to use StockMover for inventory movements --- .../DeliveryRide/DeliveryTaskController.cs | 5 +- .../DeliveryRide/DeliveryTaskService.cs | 29 ++-------- .../LoadingEvent/LoadingSheetService.cs | 57 ++----------------- .../PurchaseReturn/PurchaseReturnService.cs | 29 ++-------- 4 files changed, 21 insertions(+), 99 deletions(-) diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs index 1690ba0..0e5de5a 100644 --- a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs +++ b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs @@ -1,11 +1,12 @@ using Distribution.Models; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.DeliveryRide; [ApiController] [Route("api")] -public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase +public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase { [HttpPost("rides/{rideId:guid}/tasks/generate")] public async Task Generate(Guid rideId, CancellationToken ct) @@ -61,7 +62,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas { try { - var result = await service.ConfirmAsync(id, ct); + var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskService.cs b/Distribution/Features/DeliveryRide/DeliveryTaskService.cs index ce645bd..f68079e 100644 --- a/Distribution/Features/DeliveryRide/DeliveryTaskService.cs +++ b/Distribution/Features/DeliveryRide/DeliveryTaskService.cs @@ -12,7 +12,7 @@ public class DeliveryTaskService( IRideRepository rideRepo, IShopPORepository shopPORepo, ILoadingSheetRepository loadingSheetRepo, - IStockRepository stockRepo, + StockMover mover, IUnitOfWork uow) { private static DeliveryTaskResponse ToResponse(DeliveryTask t) => @@ -134,7 +134,7 @@ public class DeliveryTaskService( return ToResponse(saved!); } - public async Task ConfirmAsync(Guid taskId, CancellationToken ct = default) + public async Task ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default) { var task = await repo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException("Delivery task not found"); @@ -166,29 +166,12 @@ public class DeliveryTaskService( { foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0)) { - var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId); - var availableQuantity = vehicleBalance?.Quantity ?? 0; var itemName = item.Item?.Name ?? item.FK_ItemId.ToString(); - if (availableQuantity < item.QuantityFulfilled) - throw new InvalidOperationException( - $"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}"); - - vehicleBalance!.Quantity -= item.QuantityFulfilled; - vehicleBalance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(vehicleBalance); - - await stockRepo.AddMovementAsync(new StockMovement - { - FK_ItemId = item.FK_ItemId, - FromLocationId = vehicleId, - FromLocationType = LocationType.Vehicle, - ToLocationId = StockService.ExternalLocationId, - ToLocationType = LocationType.External, - Quantity = item.QuantityFulfilled, - Type = MovementType.Sale, - ReferenceId = task.FK_ShopPOId - }); + await mover.MoveAsync(item.FK_ItemId, itemName, + vehicleId, LocationType.Vehicle, + StockService.ExternalLocationId, LocationType.External, + item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId); } repo.Update(task); diff --git a/Distribution/Features/LoadingEvent/LoadingSheetService.cs b/Distribution/Features/LoadingEvent/LoadingSheetService.cs index 0afe73c..50b282d 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetService.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetService.cs @@ -8,7 +8,7 @@ namespace Distribution.Features.LoadingEvent; public class LoadingSheetService( ILoadingSheetRepository repo, IRideRepository rideRepo, - IStockRepository stockRepo, + StockMover mover, IUnitOfWork uow) { public async Task> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) @@ -154,59 +154,14 @@ public class LoadingSheetService( private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List items, Guid userId) { - // Vehicle balances created during this confirm are only tracked, not yet saved, so a - // later row for the same item would not find them via the repository and would try to - // insert a duplicate (item, vehicle) balance. - var createdVehicleBalances = new Dictionary(); - foreach (var item in items.Where(i => i.QuantityDispatched > 0)) { - var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId) - ?? throw new InvalidOperationException( - $"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'"); + var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})"; - if (warehouseBalance.Quantity < item.QuantityDispatched) - throw new InvalidOperationException( - $"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " + - $"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})"); - - warehouseBalance.Quantity -= item.QuantityDispatched; - warehouseBalance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(warehouseBalance); - - var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId) - ?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId); - if (vehicleBalance is null) - { - var created = new StockBalance - { - FK_ItemId = item.FK_ItemId, - LocationId = vehicleId, - LocationType = LocationType.Vehicle, - Quantity = item.QuantityDispatched - }; - createdVehicleBalances[item.FK_ItemId] = created; - await stockRepo.AddBalanceAsync(created); - } - else - { - vehicleBalance.Quantity += item.QuantityDispatched; - vehicleBalance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(vehicleBalance); - } - - await stockRepo.AddMovementAsync(new StockMovement - { - FK_ItemId = item.FK_ItemId, - FromLocationId = item.FK_WarehouseId, - FromLocationType = LocationType.Warehouse, - ToLocationId = vehicleId, - ToLocationType = LocationType.Vehicle, - Quantity = item.QuantityDispatched, - Type = MovementType.Load, - ReferenceId = sheetId, - CreatedBy = userId - }); + await mover.MoveAsync(item.FK_ItemId, label, + item.FK_WarehouseId, LocationType.Warehouse, + vehicleId, LocationType.Vehicle, + item.QuantityDispatched, MovementType.Load, sheetId, userId); } } diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs index 08a27d7..6af7da4 100644 --- a/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs @@ -10,7 +10,7 @@ public class PurchaseReturnService( IPurchaseReturnRepository repo, IPoRepository poRepo, IGrnRepository goodsReceiptRepo, - IStockRepository stockRepo, + StockMover mover, IUnitOfWork uow) { private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) => @@ -89,29 +89,12 @@ public class PurchaseReturnService( foreach (var line in purchaseReturn.Items) { - var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId) - ?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse."); + var itemName = po.Items.FirstOrDefault(i => i.FK_ItemId == line.FK_ItemId)?.Item?.Name ?? line.FK_ItemId.ToString(); - if (balance.Quantity < line.Quantity) - throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return."); - - balance.Quantity -= line.Quantity; - balance.UpdatedAt = DateTime.Now; - stockRepo.UpdateBalance(balance); - - await stockRepo.AddMovementAsync(new StockMovement - { - FK_ItemId = line.FK_ItemId, - FromLocationId = warehouseId, - FromLocationType = LocationType.Warehouse, - ToLocationId = StockService.ExternalLocationId, - ToLocationType = LocationType.External, - Quantity = line.Quantity, - Type = MovementType.PurchaseReturn, - ReasonCode = request.Reason, - ReferenceId = purchaseReturn.Id, - CreatedBy = createdByUserId - }); + await mover.MoveAsync(line.FK_ItemId, itemName, + warehouseId, LocationType.Warehouse, + StockService.ExternalLocationId, LocationType.External, + line.Quantity, MovementType.PurchaseReturn, purchaseReturn.Id, createdByUserId, request.Reason); } await uow.CommitAsync(ct); From c626f56cdbfb6ea7ac493fc157533375c8fe0a13 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:37:05 +0530 Subject: [PATCH 2/5] Refactor Return and Ride services to integrate StockMover and enhance error handling --- .../Features/Returns/ReturnController.cs | 2 +- .../Features/Returns/ReturnService.cs | 74 ++++++++++++++++++- Distribution/Features/Ride/RideController.cs | 7 +- Distribution/Features/Ride/RideService.cs | 30 ++++---- 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/Distribution/Features/Returns/ReturnController.cs b/Distribution/Features/Returns/ReturnController.cs index 24cfb7c..b3eb4f3 100644 --- a/Distribution/Features/Returns/ReturnController.cs +++ b/Distribution/Features/Returns/ReturnController.cs @@ -42,7 +42,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current var result = await service.CreateAsync(id, request, userId.Value, ct); return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } - catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } } } diff --git a/Distribution/Features/Returns/ReturnService.cs b/Distribution/Features/Returns/ReturnService.cs index 83ab2e5..77c6fc2 100644 --- a/Distribution/Features/Returns/ReturnService.cs +++ b/Distribution/Features/Returns/ReturnService.cs @@ -1,3 +1,4 @@ +using Distribution.Features.Category; using Distribution.Features.Item; using Distribution.Features.ShopPO; using Distribution.Features.Stock; @@ -10,7 +11,8 @@ public class ReturnService( IReturnRepository repo, IShopPORepository shopPORepo, IItemRepository itemRepo, - IStockRepository stockRepo, + ICategoryRepository categoryRepo, + StockMover mover, IUnitOfWork uow) { private static SaleReturnItemResponse MapItem(SaleReturnItem i) => @@ -57,6 +59,72 @@ public class ReturnService( public async Task> GetByShopPOIdAsync(Guid shopPOId) => (await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList(); - public Task CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) => - throw new NotImplementedException("Return flow is being redesigned — not yet implemented."); + /// + /// Stock-only: records the return and puts the goods back on the vehicle that delivered them (restoring the + /// batches/serials that were sold). Credit/exchange money handling is not applied here, so TotalCreditAmount stays 0. + /// + public async Task CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) + { + var shopPO = await shopPORepo.GetByIdAsync(shopPOId); + if (shopPO is null || !shopPO.IsActive) + throw new InvalidOperationException("Order not found."); + + if (request.Items is null || request.Items.Count == 0) + throw new InvalidOperationException("A return must have at least one line item."); + + if (request.Items.GroupBy(i => i.ItemId).Any(g => g.Count() > 1)) + throw new InvalidOperationException("Each item can appear only once on a return."); + + var saleReturn = new SaleReturn + { + FK_ShopPOId = shopPOId, + Mode = request.Mode, + TotalCreditAmount = 0, + CreatedByUserId = createdByUserId + }; + + foreach (var line in request.Items) + { + if (line.Quantity <= 0) + throw new InvalidOperationException("Return quantity must be greater than zero."); + + var shopPOItem = shopPO.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive) + ?? throw new InvalidOperationException($"Item {line.ItemId} is not on this order."); + + var item = await itemRepo.GetByIdAsync(line.ItemId) + ?? throw new InvalidOperationException("Item not found."); + + var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId) + ?? throw new InvalidOperationException("Category not found."); + + if (!category.AllowCustomerReturn) + throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'."); + + saleReturn.Items.Add(new SaleReturnItem + { + FK_ItemId = line.ItemId, + Quantity = line.Quantity, + ReasonCode = line.ReasonCode, + UnitPrice = shopPOItem.UnitPrice + }); + } + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(saleReturn); + + foreach (var line in saleReturn.Items) + { + var itemName = shopPO.Items.First(i => i.FK_ItemId == line.FK_ItemId).Item?.Name ?? line.FK_ItemId.ToString(); + await mover.ReturnToVehicleAsync(line.FK_ItemId, itemName, line.Quantity, shopPOId, createdByUserId, line.ReasonCode.ToString()); + } + + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(saleReturn.Id); + return Map(saved!); + } } diff --git a/Distribution/Features/Ride/RideController.cs b/Distribution/Features/Ride/RideController.cs index ec0086d..82660a4 100644 --- a/Distribution/Features/Ride/RideController.cs +++ b/Distribution/Features/Ride/RideController.cs @@ -1,11 +1,12 @@ using Distribution.Models; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Ride; [ApiController] [Route("api/rides")] -public class RideController(RideService service) : ControllerBase +public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] public async Task GetAll( @@ -54,7 +55,7 @@ public class RideController(RideService service) : ControllerBase { try { - var result = await service.CloseAsync(id, request, ct); + var result = await service.CloseAsync(id, request, currentUser.UserId ?? Guid.Empty, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } @@ -66,7 +67,7 @@ public class RideController(RideService service) : ControllerBase { try { - var result = await service.CancelAsync(id, request, ct); + var result = await service.CancelAsync(id, request, currentUser.UserId ?? Guid.Empty, ct); return Ok(result); } catch (KeyNotFoundException) { return NotFound(); } diff --git a/Distribution/Features/Ride/RideService.cs b/Distribution/Features/Ride/RideService.cs index 8301b2b..2e1291d 100644 --- a/Distribution/Features/Ride/RideService.cs +++ b/Distribution/Features/Ride/RideService.cs @@ -10,7 +10,7 @@ public class RideService( IRideRepository rideRepo, IDriverProfileRepository driverRepo, IDriverComplianceConfigRepository complianceRepo, - IStockRepository stockRepo, + StockMover mover, IUnitOfWork uow) { public async Task> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) @@ -76,7 +76,7 @@ public class RideService( return ToResponse(saved!); } - public async Task CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default) + public async Task CloseAsync(Guid rideId, CloseRideRequest request, Guid userId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(rideId) ?? throw new KeyNotFoundException("Ride not found"); @@ -87,20 +87,24 @@ public class RideService( if (request.Meterend < ride.Meterstart) throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})"); - await EnsureVehicleStockClearedAsync(ride.FK_VehicleId); - ride.Status = RideStatus.Closed; ride.ClosedAt = DateTime.Now; ride.Meterend = request.Meterend; + // Whatever wasn't delivered goes back to the warehouse it was loaded from. await uow.BeginAsync(ct); - try { rideRepo.Update(ride); await uow.CommitAsync(ct); } + try + { + await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideClosed"); + rideRepo.Update(ride); + await uow.CommitAsync(ct); + } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(ride); } - public async Task CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default) + public async Task CancelAsync(Guid rideId, CancelRideRequest request, Guid userId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(rideId) ?? throw new KeyNotFoundException("Ride not found"); @@ -113,7 +117,12 @@ public class RideService( ride.CancelReason = request.Reason; await uow.BeginAsync(ct); - try { rideRepo.Update(ride); await uow.CommitAsync(ct); } + try + { + await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideCancelled"); + rideRepo.Update(ride); + await uow.CommitAsync(ct); + } catch { await uow.RollbackAsync(ct); throw; } return ToResponse(ride); @@ -139,13 +148,6 @@ public class RideService( throw new InvalidOperationException("Driver medical clearance has expired"); } - private async Task EnsureVehicleStockClearedAsync(Guid vehicleId) - { - var balances = await stockRepo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle); - if (balances.Any(b => b.Quantity > 0)) - throw new InvalidOperationException("All vehicle stock must be returned before this ride can be completed"); - } - private static RideResponse ToResponse(DeliveryRun r) => new(r.Id, r.Status, r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty, From d1d2d553521214b7bf15e67b6057384b45983e27 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:41:00 +0530 Subject: [PATCH 3/5] Add stock report features r --- .../Features/Stock/IStockRepository.cs | 13 +- .../Features/Stock/StockController.cs | 20 +- Distribution/Features/Stock/StockDTOs.cs | 27 +- Distribution/Features/Stock/StockMover.cs | 234 ++++++++++++++++++ .../Features/Stock/StockRepository.cs | 124 ++++++++-- Distribution/Features/Stock/StockService.cs | 207 ++++++++-------- .../StockReport/IStockReportRepository.cs | 22 ++ .../StockReport/StockReportController.cs | 40 +++ .../Features/StockReport/StockReportDTOs.cs | 25 ++ .../StockReport/StockReportRepository.cs | 182 ++++++++++++++ .../StockReport/StockReportService.cs | 65 +++++ .../Vehicle/IVehicleDashboardRepository.cs | 21 ++ .../Features/Vehicle/VehicleController.cs | 11 +- .../Features/Vehicle/VehicleDashboardDTOs.cs | 100 ++++++++ .../Vehicle/VehicleDashboardRepository.cs | 130 ++++++++++ .../Vehicle/VehicleDashboardService.cs | 206 +++++++++++++++ 16 files changed, 1291 insertions(+), 136 deletions(-) create mode 100644 Distribution/Features/Stock/StockMover.cs create mode 100644 Distribution/Features/StockReport/IStockReportRepository.cs create mode 100644 Distribution/Features/StockReport/StockReportController.cs create mode 100644 Distribution/Features/StockReport/StockReportDTOs.cs create mode 100644 Distribution/Features/StockReport/StockReportRepository.cs create mode 100644 Distribution/Features/StockReport/StockReportService.cs create mode 100644 Distribution/Features/Vehicle/IVehicleDashboardRepository.cs create mode 100644 Distribution/Features/Vehicle/VehicleDashboardDTOs.cs create mode 100644 Distribution/Features/Vehicle/VehicleDashboardRepository.cs create mode 100644 Distribution/Features/Vehicle/VehicleDashboardService.cs diff --git a/Distribution/Features/Stock/IStockRepository.cs b/Distribution/Features/Stock/IStockRepository.cs index 57854ec..f12aba1 100644 --- a/Distribution/Features/Stock/IStockRepository.cs +++ b/Distribution/Features/Stock/IStockRepository.cs @@ -7,6 +7,8 @@ public interface IStockRepository // Balance Task GetBalanceAsync(Guid itemId, Guid locationId); Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId); + /// Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last. + Task> GetAllocatableBalancesAsync(Guid itemId, Guid locationId); Task> GetBalancesByLocationAsync(Guid locationId, LocationType locationType); Task> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize); Task GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId); @@ -19,14 +21,21 @@ public interface IStockRepository Task AddMovementAsync(StockMovement movement); Task GetMovementByIdAsync(Guid id); void UpdateMovement(StockMovement movement); - Task> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize); - Task GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to); + Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize); + Task GetMovementsCountAsync(MovementFilter filter); + /// Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against. + Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive); + + Task> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type); + /// The warehouse the item most recently arrived at from, or null if it never did. + Task GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId); // Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null). Task FindBatchAsync(Guid itemId, string batchNumber); Task AddItemBatchAsync(ItemBatch batch); // Serial numbers + Task GetSerialAsync(Guid serialId); Task SerialNumberExistsAsync(Guid itemId, string serialNumber); /// One past the highest numeric suffix among the item's existing serials that start with (1 if none). Task GetNextSerialSequenceAsync(Guid itemId, string prefix); diff --git a/Distribution/Features/Stock/StockController.cs b/Distribution/Features/Stock/StockController.cs index d9af4fd..91d449f 100644 --- a/Distribution/Features/Stock/StockController.cs +++ b/Distribution/Features/Stock/StockController.cs @@ -52,10 +52,28 @@ public class StockController(StockService service, ICurrentUserService currentUs [FromQuery] MovementType? type, [FromQuery] DateTime? from, [FromQuery] DateTime? to, + [FromQuery] Guid? referenceId, + [FromQuery] Guid? batchId, + [FromQuery] Guid? serialId, + [FromQuery] Guid? createdBy, + [FromQuery] string? reasonCode, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); + var filter = new MovementFilter(locationId, itemId, type, from, to, referenceId, batchId, serialId, createdBy, reasonCode); + var result = await service.GetMovementsAsync(filter, page, pageSize); + return Ok(result); + } + + /// Opening / in / out / closing quantity for one item at one location over a date range. + [HttpGet("movements/summary")] + public async Task GetMovementSummary( + [FromQuery] Guid itemId, + [FromQuery] Guid locationId, + [FromQuery] DateTime from, + [FromQuery] DateTime to) + { + var result = await service.GetMovementSummaryAsync(itemId, locationId, from, to); return Ok(result); } diff --git a/Distribution/Features/Stock/StockDTOs.cs b/Distribution/Features/Stock/StockDTOs.cs index a6c79c2..9af95a3 100644 --- a/Distribution/Features/Stock/StockDTOs.cs +++ b/Distribution/Features/Stock/StockDTOs.cs @@ -45,7 +45,12 @@ public record StockMovementResponse( DateTime CreatedAt, Guid CreatedBy, string? FromLocationName, - string? ToLocationName); + string? ToLocationName, + Guid? BatchId = null, + string? BatchNumber = null, + DateTime? BatchExpiryDate = null, + Guid? SerialId = null, + string? SerialNumber = null); // Write requests public record TransferRequest( @@ -82,3 +87,23 @@ public record AdjustmentRequest( // Movements are an immutable ledger — quantity, locations, and type can never change once recorded // (the running stock balance is the sum of all movements). Only the free-text reason/note is editable. public record UpdateMovementReasonRequest(string ReasonCode); + +// Movement report filters +public record MovementFilter( + Guid? LocationId = null, + Guid? ItemId = null, + MovementType? Type = null, + DateTime? From = null, + DateTime? To = null, + Guid? ReferenceId = null, + Guid? BatchId = null, + Guid? SerialId = null, + Guid? CreatedBy = null, + string? ReasonCode = null); + +public record MovementSummary( + decimal OpeningQuantity, + decimal QuantityIn, + decimal QuantityOut, + decimal ClosingQuantity, + decimal CurrentBalance); diff --git a/Distribution/Features/Stock/StockMover.cs b/Distribution/Features/Stock/StockMover.cs new file mode 100644 index 0000000..6047702 --- /dev/null +++ b/Distribution/Features/Stock/StockMover.cs @@ -0,0 +1,234 @@ +using Distribution.Features.SysConfig; +using Distribution.Models; + +namespace Distribution.Features.Stock; + +/// +/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO), +/// updates the source and destination balances, keeps serial status/location in step, and writes one +/// StockMovement per slice so the ledger always carries the batch/serial that actually moved. +/// Tracks changes only — the calling service owns the unit of work. +/// +public class StockMover(IStockRepository repo, SysConfigService sysConfig) +{ + private static readonly Guid External = StockService.ExternalLocationId; + + private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId); + + public async Task> MoveAsync( + Guid itemId, string itemName, + Guid fromId, LocationType fromType, + Guid toId, LocationType toType, + decimal quantity, MovementType type, + Guid? referenceId, Guid userId, string? reasonCode = null) + { + if (quantity <= 0) + throw new InvalidOperationException("Quantity must be greater than zero."); + + var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId); + var available = balances.Sum(b => b.Quantity); + if (available < quantity) + throw new InvalidOperationException( + $"Insufficient stock for '{itemName}': available {available}, required {quantity}"); + + var created = new List(); + var remaining = quantity; + foreach (var balance in balances) + { + if (remaining <= 0) break; + var take = Math.Min(balance.Quantity, remaining); + + balance.Quantity -= take; + balance.UpdatedAt = DateTime.Now; + repo.UpdateBalance(balance); + + if (toType != LocationType.External) + await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take); + + if (balance.ItemSerialNumber is not null) + await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type); + + var movement = new StockMovement + { + FK_ItemId = itemId, + FromLocationId = fromId, + FromLocationType = fromType, + ToLocationId = toId, + ToLocationType = toType, + FK_ItemBatchId = balance.FK_ItemBatchId, + FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId, + Quantity = take, + Type = type, + ReasonCode = reasonCode, + ReferenceId = referenceId, + CreatedBy = userId + }; + await repo.AddMovementAsync(movement); + created.Add(movement); + + remaining -= take; + } + return created; + } + + /// + /// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that + /// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned. + /// + public async Task> ReturnToVehicleAsync( + Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null) + { + if (quantity <= 0) + throw new InvalidOperationException("Quantity must be greater than zero."); + + var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale); + if (sold.Count == 0) + throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned."); + + var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn); + + var outstanding = new Dictionary(); + foreach (var m in sold) + { + var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId); + outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity; + } + foreach (var m in returned) + { + var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId); + outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity; + } + + var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value); + if (quantity > returnable) + throw new InvalidOperationException( + $"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable})."); + + var created = new List(); + var remaining = quantity; + foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0)) + { + if (remaining <= 0) break; + var take = Math.Min(qty, remaining); + + await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take); + + if (key.SerialId is Guid serialId) + { + var serial = await repo.GetSerialAsync(serialId); + if (serial is not null) + await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn); + } + + var movement = new StockMovement + { + FK_ItemId = itemId, + FromLocationId = External, + FromLocationType = LocationType.External, + ToLocationId = key.LocationId, + ToLocationType = LocationType.Vehicle, + FK_ItemBatchId = key.BatchId, + FK_ItemSerialNumberId = key.SerialId, + Quantity = take, + Type = MovementType.CustomerReturn, + ReasonCode = reasonCode, + ReferenceId = shopPoId, + CreatedBy = userId + }; + await repo.AddMovementAsync(movement); + created.Add(movement); + + remaining -= take; + } + return created; + } + + /// + /// Sends everything still on the vehicle back to the warehouse it was loaded from. + /// + public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode) + { + var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle)) + .Where(b => b.Quantity > 0) + .GroupBy(b => b.FK_ItemId); + + foreach (var group in onVehicle) + { + var item = group.First().Item; + var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key) + ?? throw new InvalidOperationException( + $"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle."); + + await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle, + warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity), + MovementType.Transfer, referenceId, userId, reasonCode); + } + } + + private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType, + Guid? batchId, Guid? serialId, decimal quantity) + { + var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId); + if (balance is null) + { + await repo.AddBalanceAsync(new StockBalance + { + FK_ItemId = itemId, + LocationId = locationId, + LocationType = locationType, + FK_ItemBatchId = batchId, + FK_ItemSerialNumberId = serialId, + Quantity = quantity + }); + return; + } + + balance.Quantity += quantity; + balance.UpdatedAt = DateTime.Now; + repo.UpdateBalance(balance); + } + + private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type) + { + switch (type) + { + case MovementType.Sale: + serial.Status = SerialStatus.Sold; + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + // Warranty clock starts at sale, not receipt. + if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync()) + { + var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync(); + serial.WarrantyExpiresAt = DateTime.Now.AddDays(days); + } + break; + + case MovementType.Damage: + serial.Status = SerialStatus.Damaged; + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + break; + + case MovementType.CustomerReturn: + serial.Status = SerialStatus.Returned; + serial.CurrentLocationId = toId; + serial.CurrentLocationType = toType; + break; + + default: + if (toType == LocationType.External) + { + serial.CurrentLocationId = null; + serial.CurrentLocationType = null; + } + else + { + if (type == MovementType.Load) serial.Status = SerialStatus.InStock; + serial.CurrentLocationId = toId; + serial.CurrentLocationType = toType; + } + break; + } + } +} diff --git a/Distribution/Features/Stock/StockRepository.cs b/Distribution/Features/Stock/StockRepository.cs index 4130c59..13c62a0 100644 --- a/Distribution/Features/Stock/StockRepository.cs +++ b/Distribution/Features/Stock/StockRepository.cs @@ -7,16 +7,39 @@ namespace Distribution.Features.Stock; public class StockRepository(AppDBContext db) : IStockRepository { public Task GetBalanceAsync(Guid itemId, Guid locationId) => - db.StockBalances - .Include(b => b.Item) - .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId - && b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null); + GetBalanceAsync(itemId, locationId, null, null); - public Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) => - db.StockBalances + // Looks at rows added earlier in the same unit of work first — they aren't in the database yet, + // so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate. + public async Task GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) + { + var pending = db.StockBalances.Local.FirstOrDefault(b => b.FK_ItemId == itemId && b.LocationId == locationId + && b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); + if (pending is not null) return pending; + + return await db.StockBalances .Include(b => b.Item) .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); + } + + public async Task> GetAllocatableBalancesAsync(Guid itemId, Guid locationId) + { + var balances = await db.StockBalances + .Include(b => b.Item) + .Include(b => b.ItemBatch) + .Include(b => b.ItemSerialNumber) + .Where(b => b.FK_ItemId == itemId && b.LocationId == locationId && b.Quantity > 0) + .ToListAsync(); + + // Tracked stock first: earliest expiry (FEFO), then oldest received serial (FIFO); untracked rows last. + return balances + .OrderBy(b => b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null ? 1 : 0) + .ThenBy(b => b.ItemBatch?.ExpiryDate ?? DateTime.MaxValue) + .ThenBy(b => b.ItemSerialNumber?.ReceivedAt ?? DateTime.MaxValue) + .ThenBy(b => b.UpdatedAt) + .ToList(); + } public Task> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) => db.StockBalances @@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository await db.StockMovements.AddAsync(movement); public Task GetMovementByIdAsync(Guid id) => - db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id); + db.StockMovements.Include(m => m.Item).Include(m => m.ItemBatch).Include(m => m.ItemSerialNumber).FirstOrDefaultAsync(m => m.Id == id); public void UpdateMovement(StockMovement movement) => db.StockMovements.Update(movement); - public Task> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) - { - var query = db.StockMovements.Include(m => m.Item).AsQueryable(); - if (locationId.HasValue) - query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); - if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); - if (type.HasValue) query = query.Where(m => m.Type == type.Value); - if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); - if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); - return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); - } - - public Task GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to) + private IQueryable FilterMovements(MovementFilter f) { var query = db.StockMovements.AsQueryable(); - if (locationId.HasValue) - query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); - if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); - if (type.HasValue) query = query.Where(m => m.Type == type.Value); - if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); - if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); - return query.CountAsync(); + if (f.LocationId.HasValue) + query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value); + if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value); + if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value); + if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value); + if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value); + if (f.SerialId.HasValue) query = query.Where(m => m.FK_ItemSerialNumberId == f.SerialId.Value); + if (f.CreatedBy.HasValue) query = query.Where(m => m.CreatedBy == f.CreatedBy.Value); + if (!string.IsNullOrWhiteSpace(f.ReasonCode)) query = query.Where(m => m.ReasonCode == f.ReasonCode); + if (f.From.HasValue) query = query.Where(m => m.CreatedAt >= f.From.Value); + if (f.To.HasValue) + { + // A bare date (midnight) means "through the end of that day". + var to = f.To.Value; + var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to; + query = query.Where(m => m.CreatedAt < toExclusive); + } + return query; } + public Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize) => + FilterMovements(filter) + .Include(m => m.Item) + .Include(m => m.ItemBatch) + .Include(m => m.ItemSerialNumber) + .OrderByDescending(m => m.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + public Task GetMovementsCountAsync(MovementFilter filter) => + FilterMovements(filter).CountAsync(); + + public async Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive) + { + var rows = db.StockMovements.AsNoTracking() + .Where(m => m.FK_ItemId == itemId && (m.FromLocationId == locationId || m.ToLocationId == locationId)); + + var before = await rows.Where(m => m.CreatedAt < from) + .GroupBy(_ => 1) + .Select(g => g.Sum(m => m.ToLocationId == locationId ? m.Quantity : 0) + - g.Sum(m => m.FromLocationId == locationId ? m.Quantity : 0)) + .FirstOrDefaultAsync(); + var inRange = rows.Where(m => m.CreatedAt >= from && m.CreatedAt < toExclusive); + var received = await inRange.Where(m => m.ToLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0; + var issued = await inRange.Where(m => m.FromLocationId == locationId).SumAsync(m => (decimal?)m.Quantity) ?? 0; + var current = await db.StockBalances.AsNoTracking() + .Where(b => b.FK_ItemId == itemId && b.LocationId == locationId) + .SumAsync(b => (decimal?)b.Quantity) ?? 0; + + return new MovementSummary(before, received, issued, before + received - issued, current); + } + + public Task> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type) => + db.StockMovements.AsNoTracking() + .Where(m => m.ReferenceId == referenceId && m.FK_ItemId == itemId && m.Type == type) + .ToListAsync(); + + public Task GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId) => + db.StockMovements.AsNoTracking() + .Where(m => m.ToLocationId == vehicleId && m.FK_ItemId == itemId + && m.FromLocationType == LocationType.Warehouse) + .OrderByDescending(m => m.CreatedAt) + .Select(m => m.FromLocationId) + .FirstOrDefaultAsync(); + public Task FindBatchAsync(Guid itemId, string batchNumber) => db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber); public async Task AddItemBatchAsync(ItemBatch batch) => await db.ItemBatches.AddAsync(batch); + public Task GetSerialAsync(Guid serialId) => + db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId); + public Task SerialNumberExistsAsync(Guid itemId, string serialNumber) => db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber); diff --git a/Distribution/Features/Stock/StockService.cs b/Distribution/Features/Stock/StockService.cs index dd29feb..176d5c5 100644 --- a/Distribution/Features/Stock/StockService.cs +++ b/Distribution/Features/Stock/StockService.cs @@ -13,6 +13,7 @@ public class StockService( ICategoryRepository categoryRepo, IWarehouseRepository warehouseRepo, IVehicleRepository vehicleRepo, + StockMover mover, IUnitOfWork uow) { private static StockBalanceResponse MapBalance(StockBalance b, string locationName) => @@ -37,7 +38,9 @@ public class StockService( private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) => new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode, - m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName); + m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName, + m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate, + m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber); private async Task MapMovementAsync(StockMovement m) { @@ -112,11 +115,16 @@ public class StockService( balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); } - public async Task> GetMovementsAsync( - Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) + public async Task GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to) { - var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); - var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to); + var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to; + return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive); + } + + public async Task> GetMovementsAsync(MovementFilter filter, int page, int pageSize) + { + var items = await repo.GetMovementsAsync(filter, page, pageSize); + var total = await repo.GetMovementsCountAsync(filter); var locations = new List<(Guid, LocationType)>(); foreach (var m in items) @@ -137,60 +145,42 @@ public class StockService( public async Task TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default) { - await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.FromLocationId, - FromLocationType = request.FromLocationType, - ToLocationId = request.ToLocationId, - ToLocationType = request.ToLocationType, - Quantity = request.Quantity, - Type = MovementType.Transfer, - ReferenceId = request.ReferenceId, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity); - await repo.AddMovementAsync(movement); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.FromLocationId, request.FromLocationType, + request.ToLocationId, request.ToLocationType, + request.Quantity, MovementType.Transfer, request.ReferenceId, userId); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default) { - await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.LocationId, - FromLocationType = request.LocationType, - ToLocationId = ExternalLocationId, - ToLocationType = LocationType.External, - Quantity = request.Quantity, - Type = MovementType.Damage, - ReasonCode = request.ReasonCode, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity); - await repo.AddMovementAsync(movement); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.LocationId, request.LocationType, + ExternalLocationId, LocationType.External, + request.Quantity, MovementType.Damage, null, userId, request.ReasonCode); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default) @@ -204,75 +194,89 @@ public class StockService( if (!category.AllowCustomerReturn) throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'."); - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = ExternalLocationId, - FromLocationType = LocationType.External, - ToLocationId = request.VehicleId, - ToLocationType = LocationType.Vehicle, - Quantity = request.Quantity, - Type = MovementType.CustomerReturn, - ReferenceId = request.ReferenceId, - CreatedBy = userId - }; + var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate; + if (tracked && request.ReferenceId is null) + throw new InvalidOperationException( + $"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on."); + List movements; await uow.BeginAsync(ct); try { - await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); - await repo.AddMovementAsync(movement); + if (tracked) + { + movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity, + request.ReferenceId!.Value, userId); + } + else + { + var movement = new StockMovement + { + FK_ItemId = request.ItemId, + FromLocationId = ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = request.VehicleId, + ToLocationType = LocationType.Vehicle, + Quantity = request.Quantity, + Type = MovementType.CustomerReturn, + ReferenceId = request.ReferenceId, + CreatedBy = userId + }; + await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); + await repo.AddMovementAsync(movement); + movements = [movement]; + } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, request.Quantity); } public async Task AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default) { - var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId); - if (balance is not null && balance.Quantity + request.Quantity < 0) - throw new InvalidOperationException("Adjustment would result in negative stock."); - - var movement = new StockMovement - { - FK_ItemId = request.ItemId, - FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId, - FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External, - ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId, - ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External, - Quantity = Math.Abs(request.Quantity), - Type = MovementType.Adjustment, - ReasonCode = request.ReasonCode, - CreatedBy = userId - }; + var item = await itemRepo.GetByIdAsync(request.ItemId) + ?? throw new InvalidOperationException("Item not found."); + List movements; await uow.BeginAsync(ct); try { - if (balance is null) + if (request.Quantity < 0) { - await repo.AddBalanceAsync(new StockBalance - { - FK_ItemId = request.ItemId, - LocationId = request.LocationId, - LocationType = request.LocationType, - Quantity = request.Quantity - }); + movements = await mover.MoveAsync(request.ItemId, item.Name, + request.LocationId, request.LocationType, + ExternalLocationId, LocationType.External, + -request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode); } else { - balance.Quantity += request.Quantity; - balance.UpdatedAt = DateTime.Now; - repo.UpdateBalance(balance); + // A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN. + if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate) + throw new InvalidOperationException( + $"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment."); + + var movement = new StockMovement + { + FK_ItemId = request.ItemId, + FromLocationId = ExternalLocationId, + FromLocationType = LocationType.External, + ToLocationId = request.LocationId, + ToLocationType = request.LocationType, + Quantity = request.Quantity, + Type = MovementType.Adjustment, + ReasonCode = request.ReasonCode, + CreatedBy = userId + }; + await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity); + await repo.AddMovementAsync(movement); + movements = [movement]; } - await repo.AddMovementAsync(movement); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } - return await MapMovementAsync(movement); + return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity)); } // Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type. @@ -282,28 +286,29 @@ public class StockService( ?? throw new InvalidOperationException("Movement not found."); movement.ReasonCode = request.ReasonCode; - repo.UpdateMovement(movement); - await uow.SaveChangesAsync(ct); + + await uow.BeginAsync(ct); + try + { + repo.UpdateMovement(movement); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } return await MapMovementAsync(movement); } // ── Helpers ──────────────────────────────────────────────────────────── - private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity) + /// + /// One logical operation can produce several movements (one per batch/serial slice). The caller gets a + /// single response for the operation: the first slice's locations, with the total quantity moved. + /// + private async Task MapMovementsAsync(List movements, Models.Item item, decimal totalQuantity) { - var balance = await repo.GetBalanceAsync(itemId, locationId); - if (balance is null || balance.Quantity < quantity) - throw new InvalidOperationException("Insufficient stock at the specified location."); - } - - private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity) - { - var balance = await repo.GetBalanceAsync(itemId, locationId) - ?? throw new InvalidOperationException("No stock balance found."); - balance.Quantity -= quantity; - balance.UpdatedAt = DateTime.Now; - repo.UpdateBalance(balance); + var first = movements[0]; + first.Item ??= item; + return (await MapMovementAsync(first)) with { Quantity = totalQuantity }; } private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) @@ -326,10 +331,4 @@ public class StockService( repo.UpdateBalance(balance); } } - - private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity) - { - await DebitBalanceAsync(itemId, fromLocationId, quantity); - await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity); - } } diff --git a/Distribution/Features/StockReport/IStockReportRepository.cs b/Distribution/Features/StockReport/IStockReportRepository.cs new file mode 100644 index 0000000..f0a4376 --- /dev/null +++ b/Distribution/Features/StockReport/IStockReportRepository.cs @@ -0,0 +1,22 @@ +namespace Distribution.Features.StockReport; + +public record LedgerAggregate( + Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom, + decimal? CostPrice, decimal? ReorderLevel, + decimal Opening, decimal In, decimal Out, decimal Current, decimal WarehouseQty); + +public record ExpiryAggregate( + Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate, + decimal Quantity, decimal? CostPrice, Guid LocationId, string LocationType, string LocationName); + +public record OverviewAggregate( + int ActiveItems, int InStockItems, int LowStockItems, decimal TotalQty, decimal TotalValue, + int ExpiringSoonBatches, int ExpiredBatches); + +public interface IStockReportRepository +{ + Task IsWarehouseAsync(Guid locationId); + Task GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays); + Task<(List Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize); + Task<(List Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize); +} diff --git a/Distribution/Features/StockReport/StockReportController.cs b/Distribution/Features/StockReport/StockReportController.cs new file mode 100644 index 0000000..2064d56 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.StockReport; + +[ApiController] +[Route("api/reports/stock")] +public class StockReportController(StockReportService service) : ControllerBase +{ + [HttpGet("overview")] + public async Task Overview([FromQuery] Guid? locationId, [FromQuery] int expiryDays = 30) + { + var error = StockReportService.ValidateExpiry(expiryDays, 1, 1); + return error is null ? Ok(await service.GetOverviewAsync(locationId, expiryDays)) : BadRequest(new { message = error }); + } + + [HttpGet("ledger")] + public async Task Ledger( + [FromQuery] Guid? locationId, [FromQuery] Guid? categoryId, [FromQuery] Guid? brandId, + [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? status = null, + [FromQuery] bool activeOnly = true, [FromQuery] string? search = null, + [FromQuery] int page = 1, [FromQuery] int pageSize = 50) + { + var end = (to ?? DateTime.Now).Date; + var start = (from ?? end.AddDays(-30)).Date; + var f = new StockReportFilter(locationId, categoryId, brandId, search, start, end, status, activeOnly); + var error = StockReportService.Validate(f, page, pageSize); + return error is null ? Ok(await service.GetLedgerAsync(f, page, pageSize)) : BadRequest(new { message = error }); + } + + [HttpGet("expiry")] + public async Task Expiry( + [FromQuery] Guid? locationId, [FromQuery] string? search = null, [FromQuery] int days = 30, + [FromQuery] int page = 1, [FromQuery] int pageSize = 50) + { + var error = StockReportService.ValidateExpiry(days, page, pageSize); + return error is null + ? Ok(await service.GetExpiryAsync(locationId, search, days, page, pageSize)) + : BadRequest(new { message = error }); + } +} diff --git a/Distribution/Features/StockReport/StockReportDTOs.cs b/Distribution/Features/StockReport/StockReportDTOs.cs new file mode 100644 index 0000000..2384a3b --- /dev/null +++ b/Distribution/Features/StockReport/StockReportDTOs.cs @@ -0,0 +1,25 @@ +namespace Distribution.Features.StockReport; + +public static class StockStatus +{ + public const string Ok = "OK"; + public const string Low = "Low"; + public const string Out = "Out"; +} + +public record StockReportFilter( + Guid? LocationId, Guid? CategoryId, Guid? BrandId, string? Search, + DateTime From, DateTime To, string? Status, bool ActiveOnly); + +public record StockOverview( + int TotalSkus, decimal TotalQty, decimal TotalValue, + int LowStockCount, int OutOfStockCount, int ExpiringSoonCount, int ExpiredCount); + +public record StockLedgerRow( + Guid ItemId, string Sku, string Name, string? Brand, string Category, string Uom, + decimal OpeningQty, decimal QtyIn, decimal QtyOut, decimal ClosingQty, decimal CurrentQty, + decimal? CostPrice, decimal StockValue, decimal? ReorderLevel, string Status); + +public record StockExpiryRow( + Guid ItemId, string Sku, string Name, Guid BatchId, string? BatchNumber, DateTime ExpiryDate, int DaysLeft, + decimal Quantity, decimal? CostPrice, decimal Value, Guid LocationId, string LocationType, string LocationName); diff --git a/Distribution/Features/StockReport/StockReportRepository.cs b/Distribution/Features/StockReport/StockReportRepository.cs new file mode 100644 index 0000000..887fde8 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportRepository.cs @@ -0,0 +1,182 @@ +using System.Linq.Expressions; +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.StockReport; + +public class StockReportRepository(AppDBContext db) : IStockReportRepository +{ + // One location when picked, otherwise every internal location (warehouses + vehicles; External is not tracked). + private IQueryable ScopedBalances(Guid? locationId) + { + var q = db.StockBalances.AsNoTracking().AsQueryable(); + return locationId.HasValue + ? q.Where(b => b.LocationId == locationId.Value) + : q.Where(b => b.LocationType == LocationType.Warehouse || b.LocationType == LocationType.Vehicle); + } + + public Task IsWarehouseAsync(Guid locationId) => + db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId); + + public async Task GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays) + { + var balances = ScopedBalances(locationId); + var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse); + + var perItem = db.Items.AsNoTracking().Where(i => i.IsActive).Select(i => new + { + i.ReorderLevel, + i.CostPrice, + Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m, + Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m, + }); + + var activeItems = await perItem.CountAsync(); + var inStockItems = await perItem.CountAsync(x => x.Qty > 0); + var lowItems = includeWarehouseLow + ? await perItem.CountAsync(x => x.Qty > 0 && x.ReorderLevel != null && x.Wh <= x.ReorderLevel) + : 0; + var totalQty = await perItem.SumAsync(x => (decimal?)x.Qty) ?? 0m; + var totalValue = await perItem.SumAsync(x => (decimal?)(x.Qty * (x.CostPrice ?? 0m))) ?? 0m; + + var soonEnd = today.AddDays(expiryDays + 1); + var batchBalances = balances.Where(b => b.FK_ItemBatchId != null && b.Quantity > 0 + && b.ItemBatch!.ExpiryDate != null); + var expired = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate < today) + .Select(b => b.FK_ItemBatchId).Distinct().CountAsync(); + var soon = await batchBalances.Where(b => b.ItemBatch!.ExpiryDate >= today && b.ItemBatch.ExpiryDate < soonEnd) + .Select(b => b.FK_ItemBatchId).Distinct().CountAsync(); + + return new OverviewAggregate(activeItems, inStockItems, lowItems, totalQty, totalValue, soon, expired); + } + + public async Task<(List Rows, int Total)> GetLedgerAsync( + StockReportFilter f, bool includeWarehouseLow, int page, int pageSize) + { + var balances = ScopedBalances(f.LocationId); + var whBalances = balances.Where(b => b.LocationType == LocationType.Warehouse); + + var items = db.Items.AsNoTracking().AsQueryable(); + if (f.ActiveOnly) items = items.Where(i => i.IsActive); + if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value); + if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value); + if (!string.IsNullOrWhiteSpace(f.Search)) + { + var s = f.Search.Trim(); + items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s)); + } + + var withQty = items.Select(i => new + { + Item = i, + Qty = balances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m, + Wh = whBalances.Where(b => b.FK_ItemId == i.Id).Sum(b => (decimal?)b.Quantity) ?? 0m, + }); + + if (f.Status == "out") + withQty = withQty.Where(x => x.Qty <= 0); + else if (f.Status == "low") + withQty = includeWarehouseLow + ? withQty.Where(x => x.Qty > 0 && x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel) + : withQty.Where(x => false); + + var total = await withQty.CountAsync(); + var pageRows = await withQty + .OrderBy(x => x.Item.Name) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new + { + x.Item.Id, x.Item.Sku, x.Item.Name, + Brand = x.Item.Brand != null ? x.Item.Brand.Name : null, + Category = x.Item.Category.Name, + Uom = x.Item.BaseUom.Name, + x.Item.CostPrice, x.Item.ReorderLevel, + x.Qty, x.Wh, + }) + .ToListAsync(); + + var ids = pageRows.Select(r => r.Id).ToList(); + var toExclusive = f.To.TimeOfDay == TimeSpan.Zero ? f.To.AddDays(1) : f.To; + var from = f.From; + + Expression> isIn, isOut; + if (f.LocationId.HasValue) + { + var loc = f.LocationId.Value; + isIn = m => m.ToLocationId == loc; + isOut = m => m.FromLocationId == loc; + } + else + { + // All locations: only movements that cross the boundary of the internal network count; + // warehouse <-> vehicle transfers net to zero and are left out of in/out. + isIn = m => m.ToLocationId != null && m.ToLocationType != LocationType.External + && (m.FromLocationId == null || m.FromLocationType == LocationType.External); + isOut = m => m.FromLocationId != null && m.FromLocationType != LocationType.External + && (m.ToLocationId == null || m.ToLocationType == LocationType.External); + } + + var moves = db.StockMovements.AsNoTracking().Where(m => ids.Contains(m.FK_ItemId) && m.CreatedAt < toExclusive); + var before = moves.Where(m => m.CreatedAt < from); + var inRange = moves.Where(m => m.CreatedAt >= from); + + var openingIn = await SumByItem(before, isIn); + var openingOut = await SumByItem(before, isOut); + var rangeIn = await SumByItem(inRange, isIn); + var rangeOut = await SumByItem(inRange, isOut); + + var rows = pageRows.Select(r => new LedgerAggregate( + r.Id, r.Sku, r.Name, r.Brand, r.Category, r.Uom, r.CostPrice, r.ReorderLevel, + openingIn.GetValueOrDefault(r.Id) - openingOut.GetValueOrDefault(r.Id), + rangeIn.GetValueOrDefault(r.Id), rangeOut.GetValueOrDefault(r.Id), + r.Qty, r.Wh)).ToList(); + return (rows, total); + } + + private static async Task> SumByItem( + IQueryable q, Expression> predicate) => + await q.Where(predicate) + .GroupBy(m => m.FK_ItemId) + .Select(g => new { ItemId = g.Key, Qty = g.Sum(m => m.Quantity) }) + .ToDictionaryAsync(x => x.ItemId, x => x.Qty); + + public async Task<(List Rows, int Total)> GetExpiryAsync( + Guid? locationId, string? search, DateTime cutoffExclusive, int page, int pageSize) + { + var q = ScopedBalances(locationId) + .Where(b => b.FK_ItemBatchId != null && b.Quantity > 0 + && b.ItemBatch!.ExpiryDate != null && b.ItemBatch.ExpiryDate < cutoffExclusive); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim(); + q = q.Where(b => b.Item.Name.Contains(s) || b.Item.Sku.Contains(s)); + } + + var total = await q.CountAsync(); + var page_ = await q + .OrderBy(b => b.ItemBatch!.ExpiryDate).ThenBy(b => b.Item.Name) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(b => new + { + b.FK_ItemId, b.Item.Sku, b.Item.Name, + BatchId = b.FK_ItemBatchId!.Value, + b.ItemBatch!.BatchNumber, + ExpiryDate = b.ItemBatch.ExpiryDate!.Value, + b.Quantity, b.Item.CostPrice, b.LocationId, b.LocationType, + }) + .ToListAsync(); + + var locIds = page_.Select(r => r.LocationId).Distinct().ToList(); + var names = await db.Warehouses.AsNoTracking().Where(w => locIds.Contains(w.Id)) + .Select(w => new { w.Id, w.Name }).ToDictionaryAsync(w => w.Id, w => w.Name); + var plates = await db.Vehicles.AsNoTracking().Where(v => locIds.Contains(v.Id)) + .Select(v => new { v.Id, v.NumberPlate }).ToDictionaryAsync(v => v.Id, v => v.NumberPlate); + + var rows = page_.Select(r => new ExpiryAggregate( + r.FK_ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate, + r.Quantity, r.CostPrice, r.LocationId, r.LocationType.ToString(), + names.GetValueOrDefault(r.LocationId) ?? plates.GetValueOrDefault(r.LocationId) ?? "—")).ToList(); + return (rows, total); + } +} diff --git a/Distribution/Features/StockReport/StockReportService.cs b/Distribution/Features/StockReport/StockReportService.cs new file mode 100644 index 0000000..8cb9866 --- /dev/null +++ b/Distribution/Features/StockReport/StockReportService.cs @@ -0,0 +1,65 @@ +using Distribution.Features.Stock; + +namespace Distribution.Features.StockReport; + +public class StockReportService(IStockReportRepository repo) +{ + public const int MaxRangeDays = 366; + public const int MaxExpiryDays = 3650; + + public static string? Validate(StockReportFilter f, int page, int pageSize) + { + if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200."; + if (f.To < f.From) return "'to' must not be before 'from'."; + if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days."; + if (f.Status is not (null or "low" or "out")) return "status must be 'low' or 'out'."; + return null; + } + + public static string? ValidateExpiry(int days, int page, int pageSize) + { + if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200."; + if (days < 0 || days > MaxExpiryDays) return $"days must be between 0 and {MaxExpiryDays}."; + return null; + } + + // Low-stock is judged on warehouse quantity, so it only applies when the scope includes warehouses. + private async Task IncludesWarehouses(Guid? locationId) => + !locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value); + + public async Task GetOverviewAsync(Guid? locationId, int expiryDays) + { + var agg = await repo.GetOverviewAsync(locationId, await IncludesWarehouses(locationId), DateTime.Now.Date, expiryDays); + return new StockOverview(agg.InStockItems, agg.TotalQty, agg.TotalValue, agg.LowStockItems, + agg.ActiveItems - agg.InStockItems, agg.ExpiringSoonBatches, agg.ExpiredBatches); + } + + public async Task> GetLedgerAsync(StockReportFilter f, int page, int pageSize) + { + var includeLow = await IncludesWarehouses(f.LocationId); + var (rows, total) = await repo.GetLedgerAsync(f, includeLow, page, pageSize); + var mapped = rows.Select(r => + { + var status = r.Current <= 0 ? StockStatus.Out + : includeLow && r.ReorderLevel != null && r.WarehouseQty <= r.ReorderLevel ? StockStatus.Low + : StockStatus.Ok; + return new StockLedgerRow(r.ItemId, r.Sku, r.Name, r.Brand, r.Category, r.Uom, + r.Opening, r.In, r.Out, r.Opening + r.In - r.Out, r.Current, + r.CostPrice, r.Current * (r.CostPrice ?? 0m), r.ReorderLevel, status); + }).ToList(); + return new PagedResponse(page, pageSize, total, mapped); + } + + public async Task> GetExpiryAsync( + Guid? locationId, string? search, int days, int page, int pageSize) + { + var today = DateTime.Now.Date; + var (rows, total) = await repo.GetExpiryAsync(locationId, search, today.AddDays(days + 1), page, pageSize); + var mapped = rows.Select(r => new StockExpiryRow( + r.ItemId, r.Sku, r.Name, r.BatchId, r.BatchNumber, r.ExpiryDate, + (int)(r.ExpiryDate.Date - today).TotalDays, + r.Quantity, r.CostPrice, r.Quantity * (r.CostPrice ?? 0m), + r.LocationId, r.LocationType, r.LocationName)).ToList(); + return new PagedResponse(page, pageSize, total, mapped); + } +} diff --git a/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs b/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs new file mode 100644 index 0000000..264a26b --- /dev/null +++ b/Distribution/Features/Vehicle/IVehicleDashboardRepository.cs @@ -0,0 +1,21 @@ +namespace Distribution.Features.Vehicle; + +public interface IVehicleDashboardRepository +{ + Task> GetStockOnBoardAsync(Guid vehicleId); + Task> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + Task GetOpenRideAsync(Guid vehicleId); + Task> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + Task GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive); + + Task> GetDriversAsync(IEnumerable driverProfileIds); + Task GetWarehouseRefAsync(Guid? warehouseId); + Task GetMobileWarehouseAsync(Guid vehicleId); + + Task> GetLocationNamesAsync(IEnumerable ids); + Task> GetUserNamesAsync(IEnumerable ids); +} diff --git a/Distribution/Features/Vehicle/VehicleController.cs b/Distribution/Features/Vehicle/VehicleController.cs index d19dd1a..899e286 100644 --- a/Distribution/Features/Vehicle/VehicleController.cs +++ b/Distribution/Features/Vehicle/VehicleController.cs @@ -4,7 +4,7 @@ namespace Distribution.Features.Vehicle; [ApiController] [Route("api/vehicles")] -public class VehicleController(VehicleService service) : ControllerBase +public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase { [HttpGet] public async Task GetAll() => @@ -17,6 +17,15 @@ public class VehicleController(VehicleService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("{id:guid}/dashboard")] + public async Task Dashboard(Guid id, [FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var error = VehicleDashboardService.Validate(from, to); + if (error is not null) return BadRequest(new { message = error }); + var result = await dashboardService.GetAsync(id, from, to); + return result is null ? NotFound() : Ok(result); + } + [HttpPost] public async Task Create(CreateVehicleRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs b/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs new file mode 100644 index 0000000..fbff798 --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardDTOs.cs @@ -0,0 +1,100 @@ +using Distribution.Models; + +namespace Distribution.Features.Vehicle; + +// ── Repository projections ───────────────────────────────────────────────── + +public record VehicleBalanceRow( + Guid ItemId, string Sku, string Name, string Category, string Uom, + decimal? SalePrice, decimal Quantity, DateTime UpdatedAt); + +public record VehicleMovementRow( + Guid Id, Guid ItemId, string ItemName, string Sku, + Guid? FromLocationId, LocationType? FromLocationType, + Guid? ToLocationId, LocationType? ToLocationType, + decimal Quantity, MovementType Type, string? ReasonCode, Guid? ReferenceId, + DateTime CreatedAt, Guid CreatedBy); + +public record VehicleRideRow( + Guid Id, RideStatus Status, Guid DriverProfileId, string DriverName, + int MeterStart, int MeterEnd, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt, + CancelRequestType? CancelType, string? CancelReason, + int Tasks, int Delivered, int Failed); + +public record VehicleTaskRow( + Guid RideId, DeliveryTaskStatus Status, Guid StoreId, string StoreName, + decimal DeliveredValue, DateTime Date); + +public record VehicleCollectionRow(PaymentMethod Method, decimal Amount, int Count); + +public record VehicleReturnTotals(int ReturnCount, decimal CreditValue, decimal Units); + +public record VehicleDriverRow( + Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry, bool IsActive); + +public record VehicleNamedRef(Guid Id, string Name); + +// ── Response ─────────────────────────────────────────────────────────────── + +public record VehicleDashboardPeriod(DateTime From, DateTime To); + +public record VehicleDashboardKpis( + int Rides, int ClosedRides, int CancelledRides, decimal DistanceKm, + int Deliveries, int DeliveredCount, int FailedCount, int PendingCount, decimal SuccessRatePercent, + decimal DeliveredValue, decimal Collections, + decimal StockOnBoardUnits, decimal StockOnBoardValue, + decimal UnitsLoaded, decimal UnitsSold); + +public record VehicleDashboardStockRow( + Guid ItemId, string Sku, string Name, string Category, string Uom, + decimal Quantity, decimal Value, DateTime UpdatedAt); + +public record VehicleDashboardRide( + Guid Id, RideStatus Status, string DriverName, DateTime? StartedAt, DateTime? ClosedAt, DateTime CreatedAt, + decimal DistanceKm, int Tasks, int Delivered, int Failed, CancelRequestType? CancelType, string? CancelReason); + +public record VehicleDashboardDailyDelivery(string Date, int Delivered, int Failed); + +public record VehicleDashboardDailyMovement(string Date, decimal In, decimal Out); + +public record VehicleDashboardMovementType(MovementType Type, string Direction, decimal Units, int Count); + +public record VehicleDashboardLedgerRow( + Guid Id, DateTime CreatedAt, Guid ItemId, string ItemName, string Sku, + MovementType Type, string Direction, decimal Quantity, + string FromName, string ToName, Guid? ReferenceId, string? ReferenceKind, + string? ReasonCode, string CreatedByName); + +public record VehicleDashboardCancelReason(CancelRequestType Type, int Count); + +public record VehicleDashboardCollection(PaymentMethod Method, decimal Amount, int Count); + +public record VehicleDashboardReturns(int ReturnCount, decimal CreditValue, decimal ReturnedUnits, decimal CustomerReturnUnitsOnVehicle); + +public record VehicleDashboardTopStore(Guid StoreId, string StoreName, int Deliveries, decimal Value); + +public record VehicleDashboardDriver( + Guid Id, string Name, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry, + bool IsActive, int Rides, decimal DistanceKm, DateTime? LastRideAt); + +public record VehicleDashboardCurrentRide( + Guid Id, string DriverName, DateTime? StartedAt, int MeterStart, int Tasks, int Delivered, int Failed); + +public record VehicleDashboardResponse( + VehicleResponse Vehicle, + VehicleNamedRef? DefaultWarehouse, + VehicleNamedRef? MobileWarehouse, + VehicleDashboardCurrentRide? CurrentRide, + VehicleDashboardPeriod Period, + VehicleDashboardKpis Kpis, + List StockOnBoard, + List Rides, + List DailyDeliveries, + List DailyMovement, + List MovementByType, + List Ledger, + List CancelReasons, + List CollectionsByMethod, + VehicleDashboardReturns Returns, + List TopStores, + List Drivers); diff --git a/Distribution/Features/Vehicle/VehicleDashboardRepository.cs b/Distribution/Features/Vehicle/VehicleDashboardRepository.cs new file mode 100644 index 0000000..1e3b6b1 --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardRepository.cs @@ -0,0 +1,130 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Vehicle; + +public class VehicleDashboardRepository(AppDBContext db) : IVehicleDashboardRepository +{ + public Task> GetStockOnBoardAsync(Guid vehicleId) => + db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Vehicle && b.LocationId == vehicleId && b.Quantity > 0) + .Select(b => new VehicleBalanceRow( + b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name, b.Item.BaseUom.Name, + b.Item.SalePrice, b.Quantity, b.UpdatedAt)) + .ToListAsync(); + + public Task> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.StockMovements.AsNoTracking() + .Where(m => (m.FromLocationId == vehicleId || m.ToLocationId == vehicleId) + && m.CreatedAt >= from && m.CreatedAt < toExclusive) + .OrderByDescending(m => m.CreatedAt) + .Select(m => new VehicleMovementRow( + m.Id, m.FK_ItemId, m.Item.Name, m.Item.Sku, + m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType, + m.Quantity, m.Type, m.ReasonCode, m.ReferenceId, m.CreatedAt, m.CreatedBy)) + .ToListAsync(); + + public Task> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.DeliveryRuns.AsNoTracking() + .Where(r => r.FK_VehicleId == vehicleId + && (r.StartedAt ?? r.CreatedAt) >= from && (r.StartedAt ?? r.CreatedAt) < toExclusive) + .OrderByDescending(r => r.StartedAt ?? r.CreatedAt) + .Select(r => new VehicleRideRow( + r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName, + r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt, + r.CancelTypeCode, r.CancelReason, + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id + && (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed))) + .ToListAsync(); + + public Task GetOpenRideAsync(Guid vehicleId) => + db.DeliveryRuns.AsNoTracking() + .Where(r => r.FK_VehicleId == vehicleId && r.Status == RideStatus.Open) + .Select(r => new VehicleRideRow( + r.Id, r.Status, r.FK_DriverProfileId, r.DriverProfile.User.FullName, + r.Meterstart, r.Meterend, r.StartedAt, r.ClosedAt, r.CreatedAt, + r.CancelTypeCode, r.CancelReason, + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id + && (t.Status == DeliveryTaskStatus.Delivered || t.Status == DeliveryTaskStatus.PartiallyDelivered)), + db.DeliveryTasks.Count(t => t.FK_RideId == r.Id && t.Status == DeliveryTaskStatus.Failed))) + .FirstOrDefaultAsync(); + + public Task> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.DeliveryTasks.AsNoTracking() + .Where(t => t.Ride.FK_VehicleId == vehicleId + && (t.Ride.StartedAt ?? t.Ride.CreatedAt) >= from && (t.Ride.StartedAt ?? t.Ride.CreatedAt) < toExclusive) + .Select(t => new VehicleTaskRow( + t.FK_RideId, t.Status, t.ShopPO.FK_StoreId, t.ShopPO.Store.StoreName, + t.ShopPO.Items.Sum(i => i.QuantityFulfilled * i.UnitPrice), + t.Ride.StartedAt ?? t.Ride.CreatedAt)) + .ToListAsync(); + + public Task> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) => + db.Payments.AsNoTracking() + .Where(p => p.CollectedAt >= from && p.CollectedAt < toExclusive + && db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == p.Invoice.FK_ShopPOId)) + .GroupBy(p => p.Method) + .Select(g => new VehicleCollectionRow(g.Key, g.Sum(p => p.Amount), g.Count())) + .ToListAsync(); + + public async Task GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive) + { + var returns = db.SaleReturns.AsNoTracking() + .Where(r => r.CreatedAt >= from && r.CreatedAt < toExclusive + && db.DeliveryTasks.Any(t => t.Ride.FK_VehicleId == vehicleId && t.FK_ShopPOId == r.FK_ShopPOId)); + var count = await returns.CountAsync(); + var credit = await returns.SumAsync(r => (decimal?)r.TotalCreditAmount) ?? 0m; + var units = await returns.SelectMany(r => r.Items).SumAsync(i => (decimal?)i.Quantity) ?? 0m; + return new VehicleReturnTotals(count, credit, units); + } + + public Task> GetDriversAsync(IEnumerable driverProfileIds) + { + var ids = driverProfileIds.Distinct().ToList(); + return db.DriverProfiles.AsNoTracking() + .Where(d => ids.Contains(d.Id)) + .Select(d => new VehicleDriverRow( + d.Id, d.User.FullName, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive)) + .ToListAsync(); + } + + public Task GetWarehouseRefAsync(Guid? warehouseId) => + warehouseId is null + ? Task.FromResult(null) + : db.Warehouses.AsNoTracking() + .Where(w => w.Id == warehouseId) + .Select(w => new VehicleNamedRef(w.Id, w.Name)) + .FirstOrDefaultAsync(); + + public Task GetMobileWarehouseAsync(Guid vehicleId) => + db.Warehouses.AsNoTracking() + .Where(w => w.VehicleId == vehicleId) + .Select(w => new VehicleNamedRef(w.Id, w.Name)) + .FirstOrDefaultAsync(); + + public async Task> GetLocationNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + var names = new Dictionary(); + if (idList.Count == 0) return names; + + foreach (var w in await db.Warehouses.AsNoTracking().Where(w => idList.Contains(w.Id)).Select(w => new { w.Id, w.Name }).ToListAsync()) + names[w.Id] = w.Name; + foreach (var v in await db.Vehicles.AsNoTracking().Where(v => idList.Contains(v.Id)).Select(v => new { v.Id, v.RegNumber }).ToListAsync()) + names[v.Id] = v.RegNumber; + return names; + } + + public async Task> GetUserNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) return []; + return await db.Users.AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName); + } +} diff --git a/Distribution/Features/Vehicle/VehicleDashboardService.cs b/Distribution/Features/Vehicle/VehicleDashboardService.cs new file mode 100644 index 0000000..40977aa --- /dev/null +++ b/Distribution/Features/Vehicle/VehicleDashboardService.cs @@ -0,0 +1,206 @@ +using Distribution.Models; + +namespace Distribution.Features.Vehicle; + +public class VehicleDashboardService(IVehicleRepository vehicleRepo, IVehicleDashboardRepository repo) +{ + private const int LedgerSize = 50; + private const int RideListSize = 15; + private const int TopListSize = 5; + private const int MaxPeriodDays = 366; + + public static string? Validate(DateTime? from, DateTime? to) + { + if (from.HasValue && to.HasValue) + { + if (from.Value.Date > to.Value.Date) return "'from' must not be after 'to'."; + if ((to.Value.Date - from.Value.Date).TotalDays >= MaxPeriodDays) return $"The period cannot exceed {MaxPeriodDays} days."; + } + return null; + } + + /// Unlike the standard vehicle lookup, this also serves inactive vehicles so their history stays viewable. + public async Task GetAsync(Guid id, DateTime? from, DateTime? to) + { + var vehicle = await vehicleRepo.GetByIdAsync(id); + if (vehicle is null) return null; + + var toDate = (to ?? DateTime.Now).Date; + var fromDate = (from ?? toDate.AddDays(-29)).Date; + var toExclusive = toDate.AddDays(1); + + var balances = await repo.GetStockOnBoardAsync(id); + var movements = (await repo.GetMovementsAsync(id, fromDate, toExclusive)) + .Select(m => (Row: m, Direction: DirectionOf(m, id))) + .Where(x => x.Direction is not null) + .ToList(); + var rides = await repo.GetRidesAsync(id, fromDate, toExclusive); + var tasks = await repo.GetTasksAsync(id, fromDate, toExclusive); + var collections = await repo.GetCollectionsAsync(id, fromDate, toExclusive); + var returnTotals = await repo.GetReturnTotalsAsync(id, fromDate, toExclusive); + var openRide = await repo.GetOpenRideAsync(id); + + // ── Stock on board ───────────────────────────────────────────────── + var stock = balances + .GroupBy(b => b.ItemId) + .Select(g => + { + var f = g.First(); + var qty = g.Sum(x => x.Quantity); + return new VehicleDashboardStockRow( + f.ItemId, f.Sku, f.Name, f.Category, f.Uom, qty, qty * (f.SalePrice ?? 0m), g.Max(x => x.UpdatedAt)); + }) + .OrderBy(r => r.Name) + .ToList(); + + // ── Rides and deliveries ─────────────────────────────────────────── + static decimal Km(VehicleRideRow r) => + r.Status == RideStatus.Closed && r.MeterEnd > r.MeterStart ? r.MeterEnd - r.MeterStart : 0m; + + var rideRows = rides.Take(RideListSize) + .Select(r => new VehicleDashboardRide( + r.Id, r.Status, r.DriverName, r.StartedAt, r.ClosedAt, r.CreatedAt, + Km(r), r.Tasks, r.Delivered, r.Failed, r.CancelType, r.CancelReason)) + .ToList(); + + bool IsDelivered(VehicleTaskRow t) => + t.Status is DeliveryTaskStatus.Delivered or DeliveryTaskStatus.PartiallyDelivered; + var delivered = tasks.Count(IsDelivered); + var failed = tasks.Count(t => t.Status == DeliveryTaskStatus.Failed); + var pending = tasks.Count(t => t.Status == DeliveryTaskStatus.Pending); + var deliveredValue = tasks.Where(IsDelivered).Sum(t => t.DeliveredValue); + + var dailyDeliveries = new List(); + var dailyMovement = new List(); + var tasksByDay = tasks.GroupBy(t => t.Date.Date).ToDictionary(g => g.Key); + var movesByDay = movements.GroupBy(x => x.Row.CreatedAt.Date).ToDictionary(g => g.Key); + for (var d = fromDate; d <= toDate; d = d.AddDays(1)) + { + var key = d.ToString("yyyy-MM-dd"); + tasksByDay.TryGetValue(d, out var dayTasks); + movesByDay.TryGetValue(d, out var dayMoves); + dailyDeliveries.Add(new VehicleDashboardDailyDelivery( + key, dayTasks?.Count(IsDelivered) ?? 0, dayTasks?.Count(t => t.Status == DeliveryTaskStatus.Failed) ?? 0)); + dailyMovement.Add(new VehicleDashboardDailyMovement( + key, + dayMoves?.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity) ?? 0m, + dayMoves?.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity) ?? 0m)); + } + + var cancelReasons = rides + .Where(r => r.Status == RideStatus.Cancelled && r.CancelType.HasValue) + .GroupBy(r => r.CancelType!.Value) + .Select(g => new VehicleDashboardCancelReason(g.Key, g.Count())) + .OrderByDescending(c => c.Count) + .ToList(); + + var topStores = tasks + .Where(IsDelivered) + .GroupBy(t => (t.StoreId, t.StoreName)) + .Select(g => new VehicleDashboardTopStore(g.Key.StoreId, g.Key.StoreName, g.Count(), g.Sum(t => t.DeliveredValue))) + .OrderByDescending(s => s.Value) + .Take(TopListSize) + .ToList(); + + // ── Movements ────────────────────────────────────────────────────── + var byType = movements + .GroupBy(x => (x.Row.Type, x.Direction)) + .Select(g => new VehicleDashboardMovementType(g.Key.Type, g.Key.Direction!, g.Sum(x => x.Row.Quantity), g.Count())) + .OrderByDescending(r => r.Units) + .ToList(); + + var ledgerSource = movements.Take(LedgerSize).ToList(); + var locationIds = ledgerSource + .SelectMany(x => new[] + { + (x.Row.FromLocationId, x.Row.FromLocationType), + (x.Row.ToLocationId, x.Row.ToLocationType), + }) + .Where(l => l.Item1.HasValue && l.Item2 is LocationType t && t != LocationType.External) + .Select(l => l.Item1!.Value); + var locationNames = await repo.GetLocationNamesAsync(locationIds); + var userNames = await repo.GetUserNamesAsync(ledgerSource.Select(x => x.Row.CreatedBy)); + + var ledger = ledgerSource.Select(x => new VehicleDashboardLedgerRow( + x.Row.Id, x.Row.CreatedAt, x.Row.ItemId, x.Row.ItemName, x.Row.Sku, + x.Row.Type, x.Direction!, x.Row.Quantity, + SideName(x.Row.Type, x.Row.FromLocationId, x.Row.FromLocationType, locationNames, isFrom: true), + SideName(x.Row.Type, x.Row.ToLocationId, x.Row.ToLocationType, locationNames, isFrom: false), + x.Row.ReferenceId, ReferenceKind(x.Row.Type), + x.Row.ReasonCode, userNames.GetValueOrDefault(x.Row.CreatedBy, "—"))).ToList(); + + // ── Drivers ──────────────────────────────────────────────────────── + var driverProfiles = (await repo.GetDriversAsync(rides.Select(r => r.DriverProfileId))) + .ToDictionary(d => d.Id); + var drivers = rides + .GroupBy(r => r.DriverProfileId) + .Where(g => driverProfiles.ContainsKey(g.Key)) + .Select(g => + { + var d = driverProfiles[g.Key]; + return new VehicleDashboardDriver( + d.Id, d.Name, d.LicenseNumber, d.LicenseExpiry, d.MedicalClearanceExpiry, d.IsActive, + g.Count(), g.Sum(Km), g.Max(r => r.StartedAt ?? r.CreatedAt)); + }) + .OrderByDescending(d => d.Rides) + .ToList(); + + // ── Assemble ─────────────────────────────────────────────────────── + var settled = delivered + failed; + var kpis = new VehicleDashboardKpis( + rides.Count, rides.Count(r => r.Status == RideStatus.Closed), rides.Count(r => r.Status == RideStatus.Cancelled), + rides.Sum(Km), + tasks.Count, delivered, failed, pending, + settled == 0 ? 0m : Math.Round(delivered * 100m / settled, 1), + deliveredValue, collections.Sum(c => c.Amount), + stock.Sum(r => r.Quantity), stock.Sum(r => r.Value), + movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "In").Sum(x => x.Row.Quantity), + movements.Where(x => x.Row.Type == MovementType.Sale && x.Direction == "Out").Sum(x => x.Row.Quantity)); + + var customerReturnUnits = movements + .Where(x => x.Row.Type == MovementType.CustomerReturn && x.Direction == "In") + .Sum(x => x.Row.Quantity); + + return new VehicleDashboardResponse( + new VehicleResponse(vehicle.Id, vehicle.RegNumber, vehicle.NumberPlate, vehicle.Type, vehicle.IsActive, vehicle.CreatedAt), + await repo.GetWarehouseRefAsync(vehicle.DefaultWarehouseId), + await repo.GetMobileWarehouseAsync(id), + openRide is null ? null : new VehicleDashboardCurrentRide( + openRide.Id, openRide.DriverName, openRide.StartedAt, openRide.MeterStart, + openRide.Tasks, openRide.Delivered, openRide.Failed), + new VehicleDashboardPeriod(fromDate, toDate), + kpis, stock, rideRows, dailyDeliveries, dailyMovement, byType, ledger, cancelReasons, + collections.Select(c => new VehicleDashboardCollection(c.Method, c.Amount, c.Count)) + .OrderByDescending(c => c.Amount).ToList(), + new VehicleDashboardReturns(returnTotals.ReturnCount, returnTotals.CreditValue, returnTotals.Units, customerReturnUnits), + topStores, drivers); + } + + private static string? DirectionOf(VehicleMovementRow m, Guid vehicleId) => + m.ToLocationId == vehicleId && m.FromLocationId != vehicleId ? "In" + : m.FromLocationId == vehicleId && m.ToLocationId != vehicleId ? "Out" + : null; + + private static string? ReferenceKind(MovementType type) => type switch + { + MovementType.Load => "Loading Sheet", + MovementType.Sale => "Order", + MovementType.CustomerReturn => "Return", + _ => null, + }; + + private static string SideName( + MovementType type, Guid? id, LocationType? locationType, Dictionary names, bool isFrom) + { + if (id is not Guid locId || locationType is not LocationType lt) return "—"; + if (lt != LocationType.External) return names.GetValueOrDefault(locId, "—"); + return type switch + { + MovementType.Sale or MovementType.CustomerReturn => "Customer", + MovementType.PurchaseReceipt or MovementType.PurchaseReturn => "Supplier", + MovementType.Damage => "Written Off", + MovementType.Adjustment => "Stock Adjustment", + _ => isFrom ? "External Source" : "External Destination", + }; + } +} From 09d86835a29e72744c418470b59ac5c98a9d20e4 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:44:35 +0530 Subject: [PATCH 4/5] Implement Warehouse Dashboard features --- .../IWarehouseDashboardRepository.cs | 23 ++ .../Features/Warehouse/WarehouseController.cs | 12 +- .../Warehouse/WarehouseDashboardDTOs.cs | 105 ++++++++ .../Warehouse/WarehouseDashboardRepository.cs | 160 ++++++++++++ .../Warehouse/WarehouseDashboardService.cs | 229 ++++++++++++++++++ Distribution/Program.cs | 8 + 6 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardRepository.cs create mode 100644 Distribution/Features/Warehouse/WarehouseDashboardService.cs diff --git a/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs b/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs new file mode 100644 index 0000000..4075787 --- /dev/null +++ b/Distribution/Features/Warehouse/IWarehouseDashboardRepository.cs @@ -0,0 +1,23 @@ +namespace Distribution.Features.Warehouse; + +public interface IWarehouseDashboardRepository +{ + Task> GetBalanceRowsAsync(Guid warehouseId); + Task> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive); + Task> GetLastOutboundAsync(Guid warehouseId); + + Task GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive); + Task> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take); + + Task> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take); + Task GetPendingDispatchUnitsAsync(Guid warehouseId); + + Task> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId); + Task> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId); + + Task GetOpenClaimCountAsync(Guid warehouseId); + Task> GetOpenClaimsAsync(Guid warehouseId, int take); + + Task> GetLocationNamesAsync(IEnumerable ids); + Task> GetUserNamesAsync(IEnumerable ids); +} diff --git a/Distribution/Features/Warehouse/WarehouseController.cs b/Distribution/Features/Warehouse/WarehouseController.cs index 043dfbd..bb0daf0 100644 --- a/Distribution/Features/Warehouse/WarehouseController.cs +++ b/Distribution/Features/Warehouse/WarehouseController.cs @@ -4,7 +4,7 @@ namespace Distribution.Features.Warehouse; [ApiController] [Route("api/warehouses")] -public class WarehouseController(WarehouseService service) : ControllerBase +public class WarehouseController(WarehouseService service, WarehouseDashboardService dashboardService) : ControllerBase { [HttpGet] public async Task GetAll() => @@ -17,6 +17,16 @@ public class WarehouseController(WarehouseService service) : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("{id:guid}/dashboard")] + public async Task Dashboard( + Guid id, [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int deadStockDays = 30) + { + var error = WarehouseDashboardService.Validate(from, to, deadStockDays); + if (error is not null) return BadRequest(new { message = error }); + var result = await dashboardService.GetAsync(id, from, to, deadStockDays); + return result is null ? NotFound() : Ok(result); + } + [HttpPost] public async Task Create(CreateWarehouseRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs b/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs new file mode 100644 index 0000000..847a3a6 --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardDTOs.cs @@ -0,0 +1,105 @@ +using Distribution.Models; + +namespace Distribution.Features.Warehouse; + +// ── Repository projections ───────────────────────────────────────────────── + +public record WarehouseBalanceRow( + Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom, + decimal? ReorderLevel, decimal? CostPrice, decimal? SalePrice, + Guid? BatchId, string? BatchNumber, DateTime? ExpiryDate, + decimal Quantity, DateTime UpdatedAt); + +public record WarehouseMovementRow( + Guid Id, Guid ItemId, string ItemName, string Sku, + Guid? FromLocationId, LocationType? FromLocationType, + Guid? ToLocationId, LocationType? ToLocationType, + decimal Quantity, MovementType Type, string? ReasonCode, Guid? ReferenceId, + DateTime CreatedAt, Guid CreatedBy); + +public record WarehouseLastOutboundRow(Guid ItemId, DateTime LastOutboundAt); + +public record WarehouseGrnTotals(int Count, int Accepted, int Rejected); + +public record WarehouseVehicleStockRow( + Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus); + +// ── Response ─────────────────────────────────────────────────────────────── + +public record DashboardPeriod(DateTime From, DateTime To); + +public record DashboardKpis( + decimal StockValueCost, decimal StockValueSale, decimal TotalUnits, int SkuCount, + int LowStockCount, int OutOfStockCount, int NearExpiryCount, int ExpiredCount, + decimal UnitsIn, decimal UnitsOut); + +public record DashboardBatch(Guid BatchId, string? BatchNumber, DateTime? ExpiryDate, decimal Quantity); + +public record DashboardStockRow( + Guid ItemId, string Sku, string Name, string Category, string? Brand, string Uom, + decimal Quantity, decimal? ReorderLevel, string Status, + decimal CostValue, decimal SaleValue, DateTime UpdatedAt, List Batches); + +public record DashboardCategoryStock(string Category, decimal Units, decimal Value); + +public record DashboardExpiringBatch( + Guid ItemId, string ItemName, string? BatchNumber, DateTime ExpiryDate, decimal Quantity, string Status); + +public record DashboardDailyPoint(string Date, decimal In, decimal Out); + +public record DashboardMovementTypeRow(MovementType Type, string Direction, decimal Units, int Count); + +public record DashboardLedgerRow( + Guid Id, DateTime CreatedAt, Guid ItemId, string ItemName, string Sku, + MovementType Type, string Direction, decimal Quantity, + string FromName, string ToName, Guid? ReferenceId, string? ReferenceKind, + string? ReasonCode, string CreatedByName); + +public record DashboardGrnRow( + Guid Id, DateTime ReceivedAt, Guid? PurchaseOrderId, string? SupplierName, + int AcceptedQuantity, int RejectedQuantity, decimal CostValue); + +public record DashboardInbound( + int GrnCount, int AcceptedUnits, int RejectedUnits, decimal RejectionRatePercent, List RecentGrns); + +public record DashboardLoadingSheetRow( + Guid Id, Guid RideId, string VehiclePlate, LoadingSheetStatus Status, + DateTime PopulatedAt, DateTime? ConfirmedAt, int Lines, decimal Units); + +public record DashboardOutbound( + decimal UnitsDispatched, decimal PendingDispatchUnits, List RecentLoadingSheets); + +public record DashboardVehicle( + Guid VehicleId, string RegNumber, string NumberPlate, bool IsLinked, decimal Units, int Skus); + +public record DashboardClaimRow(Guid Id, DateTime CreatedAt, string? SupplierName, string Details, decimal Units); + +public record DashboardLosses( + decimal PurchaseReturnUnits, decimal DamageUnits, decimal NegativeAdjustmentUnits, + decimal PositiveAdjustmentUnits, int OpenClaimCount, List OpenClaims); + +public record DashboardTopMover(Guid ItemId, string Sku, string Name, decimal UnitsOut); + +public record DashboardDeadStock( + Guid ItemId, string Sku, string Name, decimal Quantity, decimal CostValue, DateTime? LastOutboundAt); + +public record DashboardLinkedVehicle(Guid Id, string RegNumber, string NumberPlate, bool IsPrimary); + +public record WarehouseDashboardResponse( + WarehouseResponse Warehouse, + List LinkedVehicles, + DashboardPeriod Period, + int DeadStockDays, + DashboardKpis Kpis, + List Stock, + List StockByCategory, + List ExpiringBatches, + List DailyMovement, + List MovementByType, + List Ledger, + DashboardInbound Inbound, + DashboardOutbound Outbound, + List Vehicles, + DashboardLosses Losses, + List TopMovers, + List DeadStock); diff --git a/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs b/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs new file mode 100644 index 0000000..1eb169d --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardRepository.cs @@ -0,0 +1,160 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Warehouse; + +public class WarehouseDashboardRepository(AppDBContext db) : IWarehouseDashboardRepository +{ + public Task> GetBalanceRowsAsync(Guid warehouseId) => + db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == warehouseId) + .Select(b => new WarehouseBalanceRow( + b.FK_ItemId, b.Item.Sku, b.Item.Name, b.Item.Category.Name, + b.Item.Brand != null ? b.Item.Brand.Name : null, b.Item.BaseUom.Name, + b.Item.ReorderLevel, b.Item.CostPrice, b.Item.SalePrice, + b.FK_ItemBatchId, + b.ItemBatch != null ? b.ItemBatch.BatchNumber : null, + b.ItemBatch != null ? b.ItemBatch.ExpiryDate : null, + b.Quantity, b.UpdatedAt)) + .ToListAsync(); + + public Task> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive) => + db.StockMovements.AsNoTracking() + .Where(m => (m.FromLocationId == warehouseId || m.ToLocationId == warehouseId) + && m.CreatedAt >= from && m.CreatedAt < toExclusive) + .OrderByDescending(m => m.CreatedAt) + .Select(m => new WarehouseMovementRow( + m.Id, m.FK_ItemId, m.Item.Name, m.Item.Sku, + m.FromLocationId, m.FromLocationType, m.ToLocationId, m.ToLocationType, + m.Quantity, m.Type, m.ReasonCode, m.ReferenceId, m.CreatedAt, m.CreatedBy)) + .ToListAsync(); + + public Task> GetLastOutboundAsync(Guid warehouseId) => + db.StockMovements.AsNoTracking() + .Where(m => m.FromLocationId == warehouseId && m.ToLocationId != warehouseId) + .GroupBy(m => m.FK_ItemId) + .Select(g => new WarehouseLastOutboundRow(g.Key, g.Max(x => x.CreatedAt))) + .ToListAsync(); + + public async Task GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive) + { + var receipts = db.GoodsReceipts.AsNoTracking() + .Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive); + var count = await receipts.CountAsync(); + var accepted = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.AcceptedQuantity) ?? 0; + var rejected = await receipts.SelectMany(g => g.Items).SumAsync(i => (int?)i.RejectedQuantity) ?? 0; + return new WarehouseGrnTotals(count, accepted, rejected); + } + + public Task> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) => + db.GoodsReceipts.AsNoTracking() + .Where(g => g.FK_WarehouseId == warehouseId && g.ReceivedAt >= from && g.ReceivedAt < toExclusive) + .OrderByDescending(g => g.ReceivedAt) + .Take(take) + .Select(g => new DashboardGrnRow( + g.Id, g.ReceivedAt, g.FK_PurchaseOrderId, + g.PurchaseOrder != null ? g.PurchaseOrder.Supplier.Name : (g.Supplier != null ? g.Supplier.Name : null), + g.Items.Sum(i => i.AcceptedQuantity), + g.Items.Sum(i => i.RejectedQuantity), + g.Items.Sum(i => i.AcceptedQuantity * i.UnitCost))) + .ToListAsync(); + + public Task> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take) => + db.LoadingSheets.AsNoTracking() + .Where(s => s.PopulatedAt >= from && s.PopulatedAt < toExclusive + && s.Items.Any(i => i.FK_WarehouseId == warehouseId)) + .OrderByDescending(s => s.PopulatedAt) + .Take(take) + .Select(s => new DashboardLoadingSheetRow( + s.Id, s.FK_RideId, s.Ride.Vehicle.NumberPlate, s.Status, s.PopulatedAt, s.ConfirmedAt, + s.Items.Count(i => i.FK_WarehouseId == warehouseId), + s.Items.Where(i => i.FK_WarehouseId == warehouseId).Sum(i => i.QuantityDispatched))) + .ToListAsync(); + + public async Task GetPendingDispatchUnitsAsync(Guid warehouseId) => + await db.LoadingSheetItems.AsNoTracking() + .Where(i => i.FK_WarehouseId == warehouseId && i.LoadingSheet.Status == LoadingSheetStatus.Planned) + .SumAsync(i => (decimal?)i.QuantityDispatched) ?? 0m; + + public async Task> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId) + { + var loadedFromHere = db.LoadingSheetItems + .Where(i => i.FK_WarehouseId == warehouseId) + .Select(i => i.LoadingSheet.Ride.FK_VehicleId); + + var vehicles = await db.Vehicles.AsNoTracking() + .Where(v => v.IsActive + && (v.DefaultWarehouseId == warehouseId + || (primaryVehicleId != null && v.Id == primaryVehicleId) + || loadedFromHere.Contains(v.Id))) + .Select(v => new + { + v.Id, v.RegNumber, v.NumberPlate, + IsLinked = v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId), + }) + .ToListAsync(); + + var ids = vehicles.Select(v => v.Id).ToList(); + var balances = await db.StockBalances.AsNoTracking() + .Where(b => b.LocationType == LocationType.Vehicle && ids.Contains(b.LocationId) && b.Quantity > 0) + .GroupBy(b => b.LocationId) + .Select(g => new { VehicleId = g.Key, Units = g.Sum(x => x.Quantity), Skus = g.Select(x => x.FK_ItemId).Distinct().Count() }) + .ToListAsync(); + + return vehicles + .Select(v => + { + var bal = balances.FirstOrDefault(b => b.VehicleId == v.Id); + return new WarehouseVehicleStockRow(v.Id, v.RegNumber, v.NumberPlate, v.IsLinked, bal?.Units ?? 0m, bal?.Skus ?? 0); + }) + .OrderByDescending(v => v.Units) + .ToList(); + } + + public Task> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId) => + db.Vehicles.AsNoTracking() + .Where(v => v.IsActive + && (v.DefaultWarehouseId == warehouseId || (primaryVehicleId != null && v.Id == primaryVehicleId))) + .Select(v => new DashboardLinkedVehicle( + v.Id, v.RegNumber, v.NumberPlate, primaryVehicleId != null && v.Id == primaryVehicleId)) + .ToListAsync(); + + public Task GetOpenClaimCountAsync(Guid warehouseId) => + db.SupplierClaims.AsNoTracking() + .CountAsync(c => c.Status == SupplierClaimStatus.Open + && c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId); + + public Task> GetOpenClaimsAsync(Guid warehouseId, int take) => + db.SupplierClaims.AsNoTracking() + .Where(c => c.Status == SupplierClaimStatus.Open + && c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId) + .OrderByDescending(c => c.CreatedAt) + .Take(take) + .Select(c => new DashboardClaimRow( + c.Id, c.CreatedAt, c.PurchaseOrder.Supplier.Name, c.Details, + c.Items.Sum(i => i.Quantity))) + .ToListAsync(); + + public async Task> GetLocationNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + var names = new Dictionary(); + if (idList.Count == 0) return names; + + foreach (var w in await db.Warehouses.AsNoTracking().Where(w => idList.Contains(w.Id)).Select(w => new { w.Id, w.Name }).ToListAsync()) + names[w.Id] = w.Name; + foreach (var v in await db.Vehicles.AsNoTracking().Where(v => idList.Contains(v.Id)).Select(v => new { v.Id, v.RegNumber }).ToListAsync()) + names[v.Id] = v.RegNumber; + return names; + } + + public async Task> GetUserNamesAsync(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) return []; + return await db.Users.AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName); + } +} diff --git a/Distribution/Features/Warehouse/WarehouseDashboardService.cs b/Distribution/Features/Warehouse/WarehouseDashboardService.cs new file mode 100644 index 0000000..f67fc31 --- /dev/null +++ b/Distribution/Features/Warehouse/WarehouseDashboardService.cs @@ -0,0 +1,229 @@ +using Distribution.Models; + +namespace Distribution.Features.Warehouse; + +public class WarehouseDashboardService(IWarehouseRepository warehouseRepo, IWarehouseDashboardRepository repo) +{ + private const int NearExpiryDays = 30; + private const int LedgerSize = 50; + private const int RecentDocumentCount = 8; + private const int TopListSize = 5; + private const int DeadStockListSize = 10; + private const int MaxPeriodDays = 366; + + public static string? Validate(DateTime? from, DateTime? to, int deadStockDays) + { + if (deadStockDays < 1 || deadStockDays > 365) return "deadStockDays must be between 1 and 365."; + if (from.HasValue && to.HasValue) + { + if (from.Value.Date > to.Value.Date) return "'from' must not be after 'to'."; + if ((to.Value.Date - from.Value.Date).TotalDays >= MaxPeriodDays) return $"The period cannot exceed {MaxPeriodDays} days."; + } + return null; + } + + /// Unlike the standard warehouse lookup, this also serves inactive warehouses so their history stays viewable. + public async Task GetAsync(Guid id, DateTime? from, DateTime? to, int deadStockDays) + { + var warehouse = await warehouseRepo.GetByIdAsync(id); + if (warehouse is null) return null; + + var now = DateTime.Now; + var today = now.Date; + var toDate = (to ?? today).Date; + var fromDate = (from ?? toDate.AddDays(-29)).Date; + var toExclusive = toDate.AddDays(1); + + var balances = await repo.GetBalanceRowsAsync(id); + var movements = (await repo.GetMovementsAsync(id, fromDate, toExclusive)) + .Select(m => (Row: m, Direction: DirectionOf(m, id))) + .Where(x => x.Direction is not null) + .ToList(); + var lastOutbound = (await repo.GetLastOutboundAsync(id)).ToDictionary(r => r.ItemId, r => r.LastOutboundAt); + + // ── Stock on hand ────────────────────────────────────────────────── + var stock = balances + .GroupBy(b => b.ItemId) + .Select(g => BuildStockRow(g.ToList())) + .OrderBy(r => r.Name) + .ToList(); + + var stockByCategory = stock + .Where(r => r.Quantity > 0) + .GroupBy(r => r.Category) + .Select(g => new DashboardCategoryStock(g.Key, g.Sum(r => r.Quantity), g.Sum(r => r.CostValue))) + .OrderByDescending(c => c.Value) + .ToList(); + + var itemNames = stock.ToDictionary(r => r.ItemId, r => r.Name); + var expiring = balances + .Where(b => b.Quantity > 0 && b.ExpiryDate.HasValue && b.BatchId.HasValue + && b.ExpiryDate.Value.Date <= today.AddDays(NearExpiryDays)) + .GroupBy(b => (b.ItemId, b.BatchId)) + .Select(g => + { + var first = g.First(); + var expiry = first.ExpiryDate!.Value; + return new DashboardExpiringBatch( + first.ItemId, itemNames.GetValueOrDefault(first.ItemId, first.Name), first.BatchNumber, + expiry, g.Sum(x => x.Quantity), expiry.Date < today ? "Expired" : "NearExpiry"); + }) + .OrderBy(b => b.ExpiryDate) + .ToList(); + + // ── Movements ────────────────────────────────────────────────────── + var unitsIn = movements.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity); + var unitsOut = movements.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity); + + var perDay = movements + .GroupBy(x => x.Row.CreatedAt.Date) + .ToDictionary(g => g.Key, g => ( + In: g.Where(x => x.Direction == "In").Sum(x => x.Row.Quantity), + Out: g.Where(x => x.Direction == "Out").Sum(x => x.Row.Quantity))); + var daily = new List(); + for (var d = fromDate; d <= toDate; d = d.AddDays(1)) + { + perDay.TryGetValue(d, out var p); + daily.Add(new DashboardDailyPoint(d.ToString("yyyy-MM-dd"), p.In, p.Out)); + } + + var byType = movements + .GroupBy(x => (x.Row.Type, x.Direction)) + .Select(g => new DashboardMovementTypeRow(g.Key.Type, g.Key.Direction!, g.Sum(x => x.Row.Quantity), g.Count())) + .OrderByDescending(r => r.Units) + .ToList(); + + var ledgerSource = movements.Take(LedgerSize).ToList(); + var locationIds = ledgerSource + .SelectMany(x => new[] + { + (x.Row.FromLocationId, x.Row.FromLocationType), + (x.Row.ToLocationId, x.Row.ToLocationType), + }) + .Where(l => l.Item1.HasValue && l.Item2 is LocationType t && t != LocationType.External) + .Select(l => l.Item1!.Value); + var locationNames = await repo.GetLocationNamesAsync(locationIds); + var userNames = await repo.GetUserNamesAsync(ledgerSource.Select(x => x.Row.CreatedBy)); + + var ledger = ledgerSource.Select(x => new DashboardLedgerRow( + x.Row.Id, x.Row.CreatedAt, x.Row.ItemId, x.Row.ItemName, x.Row.Sku, + x.Row.Type, x.Direction!, x.Row.Quantity, + SideName(x.Row.Type, x.Row.FromLocationId, x.Row.FromLocationType, locationNames, isFrom: true), + SideName(x.Row.Type, x.Row.ToLocationId, x.Row.ToLocationType, locationNames, isFrom: false), + x.Row.ReferenceId, ReferenceKind(x.Row.Type), + x.Row.ReasonCode, userNames.GetValueOrDefault(x.Row.CreatedBy, "—"))).ToList(); + + // ── Inbound (GRN) ────────────────────────────────────────────────── + var grnTotals = await repo.GetGrnTotalsAsync(id, fromDate, toExclusive); + var received = grnTotals.Accepted + grnTotals.Rejected; + var inbound = new DashboardInbound( + grnTotals.Count, grnTotals.Accepted, grnTotals.Rejected, + received == 0 ? 0m : Math.Round(grnTotals.Rejected * 100m / received, 1), + await repo.GetRecentGrnsAsync(id, fromDate, toExclusive, RecentDocumentCount)); + + // ── Outbound (loading sheets) ────────────────────────────────────── + var outbound = new DashboardOutbound( + movements.Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out").Sum(x => x.Row.Quantity), + await repo.GetPendingDispatchUnitsAsync(id), + await repo.GetRecentLoadingSheetsAsync(id, fromDate, toExclusive, RecentDocumentCount)); + + // ── Vehicles ─────────────────────────────────────────────────────── + var vehicleStock = (await repo.GetVehicleStockAsync(id, warehouse.VehicleId)) + .Select(v => new DashboardVehicle(v.VehicleId, v.RegNumber, v.NumberPlate, v.IsLinked, v.Units, v.Skus)) + .ToList(); + var linkedVehicles = await repo.GetDefaultVehiclesAsync(id, warehouse.VehicleId); + + // ── Returns and losses ───────────────────────────────────────────── + decimal Units(MovementType type, string direction) => + movements.Where(x => x.Row.Type == type && x.Direction == direction).Sum(x => x.Row.Quantity); + var losses = new DashboardLosses( + Units(MovementType.PurchaseReturn, "Out"), Units(MovementType.Damage, "Out"), + Units(MovementType.Adjustment, "Out"), Units(MovementType.Adjustment, "In"), + await repo.GetOpenClaimCountAsync(id), + await repo.GetOpenClaimsAsync(id, RecentDocumentCount)); + + // ── Top movers and dead stock ────────────────────────────────────── + var topMovers = movements + .Where(x => x.Row.Type == MovementType.Load && x.Direction == "Out") + .GroupBy(x => x.Row.ItemId) + .Select(g => new DashboardTopMover(g.Key, g.First().Row.Sku, g.First().Row.ItemName, g.Sum(x => x.Row.Quantity))) + .OrderByDescending(m => m.UnitsOut) + .Take(TopListSize) + .ToList(); + + var deadCutoff = now.AddDays(-deadStockDays); + var deadStock = stock + .Where(r => r.Quantity > 0 && r.UpdatedAt < deadCutoff + && (!lastOutbound.TryGetValue(r.ItemId, out var last) || last < deadCutoff)) + .OrderByDescending(r => r.CostValue) + .Take(DeadStockListSize) + .Select(r => new DashboardDeadStock( + r.ItemId, r.Sku, r.Name, r.Quantity, r.CostValue, + lastOutbound.TryGetValue(r.ItemId, out var last) ? last : null)) + .ToList(); + + var kpis = new DashboardKpis( + stock.Sum(r => r.CostValue), stock.Sum(r => r.SaleValue), + stock.Where(r => r.Quantity > 0).Sum(r => r.Quantity), stock.Count(r => r.Quantity > 0), + stock.Count(r => r.Status == "Low"), stock.Count(r => r.Status == "Out"), + expiring.Count(b => b.Status == "NearExpiry"), expiring.Count(b => b.Status == "Expired"), + unitsIn, unitsOut); + + return new WarehouseDashboardResponse( + new WarehouseResponse(warehouse.Id, warehouse.Name, warehouse.Location, warehouse.Type, + warehouse.VehicleId, warehouse.IsActive, warehouse.CreatedAt), + linkedVehicles, new DashboardPeriod(fromDate, toDate), deadStockDays, kpis, + stock, stockByCategory, expiring, daily, byType, ledger, + inbound, outbound, vehicleStock, losses, topMovers, deadStock); + } + + private static DashboardStockRow BuildStockRow(List rows) + { + var first = rows[0]; + var quantity = rows.Sum(r => r.Quantity); + var billable = Math.Max(quantity, 0m); + var status = quantity <= 0 ? "Out" + : first.ReorderLevel.HasValue && quantity < first.ReorderLevel.Value ? "Low" + : "OK"; + var batches = rows + .Where(r => r.BatchId.HasValue && r.Quantity > 0) + .GroupBy(r => r.BatchId!.Value) + .Select(g => new DashboardBatch(g.Key, g.First().BatchNumber, g.First().ExpiryDate, g.Sum(r => r.Quantity))) + .OrderBy(b => b.ExpiryDate ?? DateTime.MaxValue) + .ToList(); + + return new DashboardStockRow( + first.ItemId, first.Sku, first.Name, first.Category, first.Brand, first.Uom, + quantity, first.ReorderLevel, status, + billable * (first.CostPrice ?? 0m), billable * (first.SalePrice ?? 0m), + rows.Max(r => r.UpdatedAt), batches); + } + + private static string? DirectionOf(WarehouseMovementRow m, Guid warehouseId) => + m.ToLocationId == warehouseId && m.FromLocationId != warehouseId ? "In" + : m.FromLocationId == warehouseId && m.ToLocationId != warehouseId ? "Out" + : null; + + private static string? ReferenceKind(MovementType type) => type switch + { + MovementType.PurchaseReceipt => "GRN", + MovementType.Load => "Loading Sheet", + MovementType.PurchaseReturn => "Purchase Return", + _ => null, + }; + + private static string SideName( + MovementType type, Guid? id, LocationType? locationType, Dictionary names, bool isFrom) + { + if (id is not Guid locId || locationType is not LocationType lt) return "—"; + if (lt != LocationType.External) return names.GetValueOrDefault(locId, "—"); + return type switch + { + MovementType.Sale or MovementType.CustomerReturn => "Customer", + MovementType.PurchaseReceipt or MovementType.PurchaseReturn => "Supplier", + MovementType.Damage => "Written Off", + MovementType.Adjustment => "Stock Adjustment", + _ => isFrom ? "External Source" : "External Destination", + }; + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 19dc98e..44c9a40 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -32,6 +32,7 @@ using Distribution.Features.User; using Distribution.Features.Vehicle; using Distribution.Features.ProductReport; using Distribution.Features.Stock; +using Distribution.Features.StockReport; using Distribution.Features.SysConfig; using Distribution.Features.Warehouse; using Distribution.Infra.Authorization; @@ -51,8 +52,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -65,6 +68,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -85,8 +90,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -98,6 +105,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From c5a0992b5d5dab298500ed5d6c32644d0de34bce Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 16 Oct 2026 15:47:23 +0530 Subject: [PATCH 5/5] Fe changes --- .../_components/dashboard-charts.tsx | 142 +++++++ .../_components/dashboard-client.tsx | 198 +++++---- .../network/vehicles/[id]/page.tsx | 5 + .../_components/dashboard/sections.tsx | 385 ++++++++++++++++++ .../dashboard/vehicle-dashboard.tsx | 236 +++++++++++ .../vehicles/_components/vehicles-client.tsx | 9 +- .../network/warehouses/[id]/page.tsx | 5 + .../dashboard/activity-section.tsx | 217 ++++++++++ .../_components/dashboard/format.ts | 56 +++ .../dashboard/movement-section.tsx | 153 +++++++ .../_components/dashboard/shared.tsx | 62 +++ .../_components/dashboard/stock-section.tsx | 226 ++++++++++ .../dashboard/warehouse-dashboard.tsx | 235 +++++++++++ .../_components/warehouses-client.tsx | 9 +- .../_components/product-report-client.tsx | 67 +++ .../stock/_components/stock-report-client.tsx | 331 +++++++++++++++ .../app/(dashboard)/reports/stock/page.tsx | 5 + .../components/shared/export-buttons.tsx | 39 ++ distribution_fe/config/nav.ts | 1 + distribution_fe/lib/api/master.ts | 9 + distribution_fe/lib/api/reports.ts | 74 ++++ distribution_fe/lib/export.ts | 81 ++++ distribution_fe/types/api.ts | 259 ++++++++++++ 23 files changed, 2715 insertions(+), 89 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx create mode 100644 distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx create mode 100644 distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx create mode 100644 distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx create mode 100644 distribution_fe/app/(dashboard)/reports/stock/page.tsx create mode 100644 distribution_fe/components/shared/export-buttons.tsx create mode 100644 distribution_fe/lib/export.ts diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx new file mode 100644 index 0000000..9b1b3ed --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx @@ -0,0 +1,142 @@ +'use client' + +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts' + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' + +export interface ChartDatum { + name: string + value: number +} + +export interface MonthlySalesPoint { + month: string // yyyy-MM + invoiced: number +} + +const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 }) +const fmtMonth = (v: string) => new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' }) + +const SLICE_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)', 'var(--chart-3)', 'var(--destructive)'] + +function Empty({ text }: { text: string }) { + return

{text}

+} + +export function DonutChart({ data, centerLabel, emptyText }: { data: ChartDatum[]; centerLabel: string; emptyText: string }) { + const total = data.reduce((sum, d) => sum + d.value, 0) + if (total === 0) return + + const slices = data.map((d, i) => ({ ...d, fill: SLICE_COLORS[i % SLICE_COLORS.length] })) + const config = Object.fromEntries(slices.map(s => [s.name, { label: s.name, color: s.fill }])) as ChartConfig + + return ( + + + } /> + + + } /> + + + ) +} + +export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)' }: { data: ChartDatum[]; emptyText: string; color?: string }) { + if (data.length === 0 || data.every(d => d.value === 0)) return + + const config = { value: { label: 'Quantity', color } } satisfies ChartConfig + + return ( + + + + + + } /> + + {data.map(d => )} + + + + ) +} + +export function ColumnBarChart({ data, emptyText, label }: { data: ChartDatum[]; emptyText: string; label: string }) { + if (data.length === 0 || data.every(d => d.value === 0)) return + + const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( + + + + + + } /> + + + + ) +} + +export function SalesTrendChart({ data }: { data: MonthlySalesPoint[] }) { + if (data.length === 0) return + + const config = { invoiced: { label: 'Invoiced', color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( + + + + + + + + + + + + fmtMonth(String(v))} />} /> + + + + ) +} + +export function RadialGauge({ pct, label, sub }: { pct: number; label: string; sub: string }) { + const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig + + return ( +
+
+ + + + + + +
{pct}%
+
+

{sub}

+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx index c2d1997..f681a93 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx @@ -22,6 +22,8 @@ import { getPaginatedItems, } from '@/lib/api/index' import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' +import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts' +import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' const STORAGE_KEY = 'distribution-dashboard-widgets-v3' const ROW_HEIGHT = 36 @@ -31,7 +33,7 @@ type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] type Category = 'sales' | 'ops' | 'stock' | 'finance' -type Size = 'sm' | 'md' | 'lg' +type Size = 'sm' | 'md' | 'lg' | 'chart' | 'chartWide' interface WidgetMeta { id: string @@ -59,11 +61,18 @@ const WIDGET_META: WidgetMeta[] = [ { id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' }, { id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' }, { id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' }, + { id: 'salesTrendChart', cat: 'finance', title: 'Sales Trend (6 months)', size: 'chartWide' }, + { id: 'paymentStatusChart', cat: 'finance', title: 'Payment Status', size: 'chart' }, + { id: 'poStatusChart', cat: 'sales', title: 'Shop POs by Status', size: 'chart' }, + { id: 'loadingSheetsChart', cat: 'ops', title: 'Loading Sheets Split', size: 'chart' }, + { id: 'warehouseChart', cat: 'stock', title: 'Warehouse Stock Chart', size: 'chart' }, + { id: 'movementTypeChart', cat: 'stock', title: 'Movements by Type', size: 'chart' }, ] const DEFAULT_ACTIVE = [ 'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus', 'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus', + 'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart', ] /** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints @@ -71,28 +80,38 @@ const DEFAULT_ACTIVE = [ const SIZE_CONFIG: Record> = { lg: { sm: { w: 3, h: 4, minW: 2, minH: 3 }, - md: { w: 6, h: 5, minW: 3, minH: 3 }, + md: { w: 6, h: 6, minW: 3, minH: 3 }, lg: { w: 12, h: 7, minW: 4, minH: 4 }, + chart: { w: 4, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 8, h: 7, minW: 4, minH: 5 }, }, md: { sm: { w: 5, h: 4, minW: 3, minH: 3 }, - md: { w: 10, h: 5, minW: 4, minH: 3 }, + md: { w: 10, h: 6, minW: 4, minH: 3 }, lg: { w: 10, h: 7, minW: 4, minH: 4 }, + chart: { w: 5, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 10, h: 7, minW: 4, minH: 5 }, }, sm: { sm: { w: 3, h: 4, minW: 2, minH: 3 }, - md: { w: 6, h: 5, minW: 3, minH: 3 }, + md: { w: 6, h: 6, minW: 3, minH: 3 }, lg: { w: 6, h: 7, minW: 3, minH: 4 }, + chart: { w: 6, h: 7, minW: 3, minH: 5 }, + chartWide: { w: 6, h: 7, minW: 3, minH: 5 }, }, xs: { sm: { w: 4, h: 4, minW: 2, minH: 3 }, - md: { w: 4, h: 5, minW: 2, minH: 3 }, + md: { w: 4, h: 6, minW: 2, minH: 3 }, lg: { w: 4, h: 7, minW: 2, minH: 4 }, + chart: { w: 4, h: 7, minW: 2, minH: 5 }, + chartWide: { w: 4, h: 7, minW: 2, minH: 5 }, }, xxs: { sm: { w: 2, h: 4, minW: 1, minH: 3 }, - md: { w: 2, h: 5, minW: 1, minH: 3 }, + md: { w: 2, h: 6, minW: 1, minH: 3 }, lg: { w: 2, h: 7, minW: 1, minH: 4 }, + chart: { w: 2, h: 7, minW: 1, minH: 5 }, + chartWide: { w: 2, h: 7, minW: 1, minH: 5 }, }, } @@ -198,6 +217,32 @@ interface DashboardData { draftInvoiceCount: number printedInvoiceCount: number finalizedInvoiceCount: number + poStatusChart: ChartDatum[] + paymentStatusChart: ChartDatum[] + movementTypeChart: ChartDatum[] + monthlySales: MonthlySalesPoint[] +} + +const PO_STATUSES = ['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled'] as const +const PAYMENT_STATUSES = ['Pending', 'PartiallyPaid', 'Paid'] as const +const SALES_MONTHS = 6 +/** Recent-activity charts aggregate this many latest records client-side. */ +const CHART_SAMPLE_SIZE = 200 + +const splitWords = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2') + +function buildMonthlySales(rows: Array<{ createdAt: string; totalAmount: number }>): MonthlySalesPoint[] { + const now = new Date() + const months: MonthlySalesPoint[] = [] + for (let i = SALES_MONTHS - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + months.push({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, invoiced: 0 }) + } + for (const r of rows) { + const bucket = months.find(m => m.month === r.createdAt.slice(0, 7)) + if (bucket) bucket.invoiced += r.totalAmount + } + return months } async function loadDashboardData(): Promise { @@ -211,6 +256,8 @@ async function loadDashboardData(): Promise { balances, outstanding, draftInv, printedInv, finalizedInv, + poStatusCounts, paymentStatusCounts, + recentInvoices, recentMovements, ] = await Promise.all([ shopPos.list({ status: 'Pending', pageSize: 1 }), shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }), @@ -228,8 +275,17 @@ async function loadDashboardData(): Promise { invoices.list({ status: 'Draft', pageSize: 1 }), invoices.list({ status: 'Printed', pageSize: 1 }), invoices.list({ status: 'Finalized', pageSize: 1 }), + Promise.all(PO_STATUSES.map(status => shopPos.list({ status, pageSize: 1 }))), + Promise.all(PAYMENT_STATUSES.map(paymentStatus => invoices.list({ paymentStatus, pageSize: 1 }))), + invoices.list({ pageSize: CHART_SAMPLE_SIZE }), + stock.movements({ pageSize: CHART_SAMPLE_SIZE }), ]) + const movementCounts = new Map() + for (const m of getPaginatedItems(recentMovements)) { + movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1) + } + const balanceRows = getPaginatedItems(balances) const byWarehouse = new Map() for (const b of balanceRows) { @@ -259,6 +315,10 @@ async function loadDashboardData(): Promise { draftInvoiceCount: draftInv.total, printedInvoiceCount: printedInv.total, finalizedInvoiceCount: finalizedInv.total, + poStatusChart: PO_STATUSES.map((s, i) => ({ name: splitWords(s), value: poStatusCounts[i].total })), + paymentStatusChart: PAYMENT_STATUSES.map((s, i) => ({ name: splitWords(s), value: paymentStatusCounts[i].total })), + movementTypeChart: Array.from(movementCounts, ([type, value]) => ({ name: splitWords(type), value })).sort((a, b) => b.value - a.value), + monthlySales: buildMonthlySales(getPaginatedItems(recentInvoices)), } } @@ -271,14 +331,6 @@ function CategoryPill({ cat }: { cat: Category }) { ) } -function Bar({ pct, colorClass }: { pct: number; colorClass: string }) { - return ( -
-
-
- ) -} - function WidgetBody({ id, data }: { id: string; data: DashboardData }) { switch (id) { case 'pendingPO': @@ -297,13 +349,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ) case 'fulfillRate': { const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0 - return ( -
-
{pct}%
- -
{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled
-
- ) + return } case 'openRides': return data.openRides.length === 0 ? ( @@ -321,29 +367,16 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ))}
) - case 'loadingSheetsByStatus': { - const total = data.plannedSheetsCount + data.confirmedSheetsCount - const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0 - const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0 + case 'loadingSheetsByStatus': return ( -
-
-
- Planned - {data.plannedSheetsCount} -
- -
-
-
- Confirmed - {data.confirmedSheetsCount} -
- -
-
+ ) - } case 'deliveryTasks': return (
@@ -387,24 +420,8 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { ))}
) - case 'warehouseBalance': { - const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity)) - return data.warehouseBalances.length === 0 ? ( -

No warehouse stock recorded.

- ) : ( -
- {data.warehouseBalances.map(w => ( -
-
- {w.name} - {w.quantity} -
- -
- ))} -
- ) - } + case 'warehouseBalance': + return ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." /> case 'outstandingPay': return (
@@ -412,34 +429,39 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
{data.outstandingInvoiceCount} invoices
) - case 'invoiceStatus': { - const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount - const segs = [ - { label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' }, - { label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' }, - { label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' }, - ] + case 'invoiceStatus': return ( -
-
- {segs.map(s => ( -
0 ? `${(s.count / total) * 100}%` : '0%' }} /> - ))} -
-
- {segs.map(s => ( -
- - - {s.label} - - {s.count} -
- ))} -
-
+ ) - } + case 'salesTrendChart': + return + case 'paymentStatusChart': + return + case 'poStatusChart': + return + case 'loadingSheetsChart': + return ( + + ) + case 'warehouseChart': + return ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." /> + case 'movementTypeChart': + return default: return null } @@ -653,7 +675,9 @@ export function DashboardClient() {
{w.title}
- +
+ +
))} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx b/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx new file mode 100644 index 0000000..018b6aa --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/[id]/page.tsx @@ -0,0 +1,5 @@ +import { VehicleDashboard } from '../_components/dashboard/vehicle-dashboard' + +export default function VehicleDashboardPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx new file mode 100644 index 0000000..3adea22 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/sections.tsx @@ -0,0 +1,385 @@ +'use client' + +import { useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + MOVEMENT_LABEL, daysAgo, fmtCompact, fmtDate, fmtDateTime, fmtMoney, fmtNum, fmtShortDay, +} from '@/app/(dashboard)/network/warehouses/_components/dashboard/format' +import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared' +import type { CancelRequestType, RideStatus, VehicleDashboardResponse } from '@/types/api' + +const CANCEL_LABEL: Record = { + VehicleBreakdown: 'Vehicle breakdown', + DriverUnavailable: 'Driver unavailable', + RouteIssue: 'Route issue', + Other: 'Other', +} + +const RIDE_BADGE: Record = { + Open: 'default', + Closed: 'secondary', + Cancelled: 'destructive', +} + +const deliveryConfig = { + delivered: { label: 'Delivered', color: 'var(--chart-2)' }, + failed: { label: 'Failed', color: 'var(--destructive)' }, +} satisfies ChartConfig + +const flowConfig = { + in: { label: 'Units in', color: 'var(--chart-2)' }, + out: { label: 'Units out', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +export function expiryState(date: string): 'expired' | 'soon' | 'ok' { + const days = (new Date(date).getTime() - Date.now()) / 86_400_000 + return days < 0 ? 'expired' : days <= 30 ? 'soon' : 'ok' +} + +// ── Rides & deliveries ────────────────────────────────────────────────────── + +export function RidesSection({ data }: { data: VehicleDashboardResponse }) { + const { kpis, rides, cancelReasons, topStores } = data + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ {rides.length === 0 ? ( + No rides in this period. + ) : ( +
+ + + + Started + Driver + Status + Distance + Delivered + Failed + Notes + + + + {rides.map(r => ( + + {fmtDateTime(r.startedAt ?? r.createdAt)} + {r.driverName} + {r.status} + {r.distanceKm > 0 ? `${fmtNum(r.distanceKm)} km` : '—'} + {r.delivered} / {r.tasks} + {r.failed} + + {r.status === 'Cancelled' + ? `${r.cancelType ? CANCEL_LABEL[r.cancelType] : 'Cancelled'}${r.cancelReason ? ` — ${r.cancelReason}` : ''}` + : r.closedAt ? `Closed ${fmtDateTime(r.closedAt)}` : '—'} + + + ))} + +
+
+ )} +
+ +
+
+ {topStores.length === 0 ? ( + No completed deliveries in this period. + ) : ( +
    + {topStores.map(s => ( +
  • + {s.storeName} + + {s.deliveries} {s.deliveries === 1 ? 'delivery' : 'deliveries'} · {fmtMoney(s.value)} + +
  • + ))} +
+ )} +
+ +
+ {cancelReasons.length === 0 ? ( + No cancelled rides in this period. + ) : ( +
    + {cancelReasons.map(c => ( +
  • + {CANCEL_LABEL[c.type]} + {c.count} +
  • + ))} +
+ )} +
+
+
+ ) +} + +// ── Stock on board ────────────────────────────────────────────────────────── + +export function StockSection({ data }: { data: VehicleDashboardResponse }) { + const { stockOnBoard, kpis } = data + return ( +
+
+ + + + +
+ {stockOnBoard.length === 0 ? ( + The vehicle is empty. + ) : ( +
+ + + + Item + Category + On board + Value + Updated + + + + {stockOnBoard.map(r => ( + + +

{r.name}

+

{r.sku}

+
+ {r.category} + {fmtNum(r.quantity)} {r.uom} + {fmtMoney(r.value)} + {daysAgo(r.updatedAt)} +
+ ))} +
+
+
+ )} +
+ ) +} + +// ── Stock movement ────────────────────────────────────────────────────────── + +export function MovementSection({ data }: { data: VehicleDashboardResponse }) { + const [direction, setDirection] = useState<'all' | 'In' | 'Out'>('all') + const ledger = direction === 'all' ? data.ledger : data.ledger.filter(r => r.direction === direction) + const total = data.movementByType.reduce((sum, r) => sum + r.count, 0) + + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ {data.movementByType.length === 0 ? ( + No movements in this period. + ) : ( +
    + {data.movementByType.map(r => ( +
  • + + {r.direction} + {MOVEMENT_LABEL[r.type]} + + {fmtNum(r.units)} units · {r.count}× +
  • + ))} +
+ )} +
+ +
+
+ {(['all', 'In', 'Out'] as const).map(d => ( + + ))} +
+ {ledger.length === 0 ? ( + No movements to show. + ) : ( +
+ + + + When + Item + Type + Qty + From → To + By + + + + {ledger.map(r => ( + + {fmtDateTime(r.createdAt)} + +

{r.itemName}

+

{r.sku}

+
+ +
+ {r.direction} + {MOVEMENT_LABEL[r.type]} +
+
+ {r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)} + {r.fromName} → {r.toName} + {r.createdByName} +
+ ))} +
+
+
+ )} +
+
+ ) +} + +// ── Drivers, collections and returns ──────────────────────────────────────── + +function ExpiryBadge({ date }: { date: string }) { + const state = expiryState(date) + return ( + + {fmtDate(date)} + {state === 'expired' && Expired} + {state === 'soon' && Expiring} + + ) +} + +export function PeopleMoneySection({ data }: { data: VehicleDashboardResponse }) { + const { drivers, collectionsByMethod, returns, kpis } = data + return ( +
+
+ {drivers.length === 0 ? ( + No driver has used this vehicle in this period. + ) : ( +
+ + + + Driver + Rides + Distance + Last ride + Licence expiry + Medical clearance + + + + {drivers.map(d => ( + + +

{d.name}{!d.isActive && Inactive}

+

{d.licenseNumber}

+
+ {d.rides} + {d.distanceKm > 0 ? `${fmtNum(d.distanceKm)} km` : '—'} + {d.lastRideAt ? daysAgo(d.lastRideAt) : '—'} + + +
+ ))} +
+
+
+ )} +
+ +
+
+
+ + +
+ {collectionsByMethod.length === 0 ? ( + No payments collected in this period. + ) : ( +
    + {collectionsByMethod.map(c => ( +
  • + {c.method} + {fmtMoney(c.amount)} · {c.count} {c.count === 1 ? 'payment' : 'payments'} +
  • + ))} +
+ )} +
+ +
+
+ + + + +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx new file mode 100644 index 0000000..e8d7877 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/dashboard/vehicle-dashboard.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { toast } from '@/components/ui/toast' +import { vehicles as vehiclesApi, getApiError } from '@/lib/api/index' +import { daysFromToday, fmtDate, fmtDateTime, fmtMoney, fmtNum } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format' +import { Metric } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared' +import type { VehicleDashboardResponse } from '@/types/api' + +import { VehicleSheet } from '../vehicle-sheet' +import { MovementSection, PeopleMoneySection, RidesSection, StockSection, expiryState } from './sections' + +const PRESETS = [ + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, +] + +type Alert = { severity: 'danger' | 'warning' | 'info'; text: string } + +function buildAlerts(d: VehicleDashboardResponse): Alert[] { + const alerts: Alert[] = [] + const { kpis, currentRide, drivers } = d + + if (!d.vehicle.isActive) + alerts.push({ severity: 'warning', text: 'This vehicle is inactive.' }) + + if (currentRide) + alerts.push({ + severity: 'info', + text: `Ride in progress with ${currentRide.driverName}${currentRide.startedAt ? ` since ${fmtDateTime(currentRide.startedAt)}` : ''} — ${currentRide.delivered}/${currentRide.tasks} stops delivered`, + }) + else if (kpis.stockOnBoardUnits > 0) + alerts.push({ + severity: 'danger', + text: `${fmtNum(kpis.stockOnBoardUnits)} units are still on the vehicle but there is no open ride — return them to a warehouse`, + }) + + for (const dr of drivers) { + const licence = expiryState(dr.licenseExpiry) + const medical = expiryState(dr.medicalClearanceExpiry) + if (licence === 'expired') alerts.push({ severity: 'danger', text: `${dr.name}'s licence expired on ${fmtDate(dr.licenseExpiry)}` }) + else if (licence === 'soon') alerts.push({ severity: 'warning', text: `${dr.name}'s licence expires on ${fmtDate(dr.licenseExpiry)}` }) + if (medical === 'expired') alerts.push({ severity: 'danger', text: `${dr.name}'s medical clearance expired on ${fmtDate(dr.medicalClearanceExpiry)}` }) + else if (medical === 'soon') alerts.push({ severity: 'warning', text: `${dr.name}'s medical clearance expires on ${fmtDate(dr.medicalClearanceExpiry)}` }) + } + + if (kpis.failedCount > 0) + alerts.push({ severity: 'warning', text: `${kpis.failedCount} failed deliver${kpis.failedCount > 1 ? 'ies' : 'y'} this period (${kpis.successRatePercent}% success rate)` }) + if (kpis.cancelledRides > 0) + alerts.push({ severity: 'warning', text: `${kpis.cancelledRides} ride${kpis.cancelledRides > 1 ? 's' : ''} cancelled this period` }) + + return alerts +} + +const ALERT_BADGE = { + danger: { label: 'Critical', variant: 'destructive' }, + warning: { label: 'Attention', variant: 'outline' }, + info: { label: 'Info', variant: 'secondary' }, +} as const + +export function VehicleDashboard() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + + const [from, setFrom] = useState(() => daysFromToday(-29)) + const [to, setTo] = useState(() => daysFromToday(0)) + const [data, setData] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + const [loadedKey, setLoadedKey] = useState('') + const [editOpen, setEditOpen] = useState(false) + + const requestKey = `${id}|${from}|${to}|${reloadKey}` + const loading = loadedKey !== requestKey + const reload = () => setReloadKey(k => k + 1) + + useEffect(() => { + let cancelled = false + vehiclesApi.dashboard(id, { from, to }) + .then(res => { if (!cancelled) setData(res) }) + .catch(err => { + if (!cancelled) toast.add({ title: 'Failed to load vehicle dashboard', description: getApiError(err), type: 'error' }) + }) + .finally(() => { if (!cancelled) setLoadedKey(requestKey) }) + return () => { cancelled = true } + }, [id, from, to, requestKey]) + + const alerts = useMemo(() => (data ? buildAlerts(data) : []), [data]) + + function applyPreset(days: number) { + setFrom(daysFromToday(-(days - 1))) + setTo(daysFromToday(0)) + } + + const vehicle = data?.vehicle + const k = data?.kpis + + return ( +
+ {/* Header */} +
+
+ +
+ {!vehicle ? ( + + ) : ( +
+

{vehicle.numberPlate}

+ {vehicle.type} + + {vehicle.isActive ? 'Active' : 'Inactive'} + + {data?.currentRide && On a ride} +
+ )} +

+ {vehicle ? `Reg. ${vehicle.regNumber} · Added ${fmtDate(vehicle.createdAt)}` : 'Vehicle overview'} + {data?.defaultWarehouse && ( + <> · Home warehouse: {data.defaultWarehouse.name} + )} + {data?.mobileWarehouse && ( + <> · Mobile warehouse: {data.mobileWarehouse.name} + )} +

+
+
+ + {vehicle && ( + + )} +
+ + {/* Period */} +
+ Activity period + {PRESETS.map(p => ( + + ))} + e.target.value && setFrom(e.target.value)} className="w-40" /> + to + e.target.value && setTo(e.target.value)} className="w-40" /> +
+ + {!data && loading && ( +
+
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ +
+ )} + + {data && k && ( +
+ {/* KPIs */} +
+ + + 0 ? 'warning' : undefined} + /> + + + +
+ + {/* Alerts */} + + + Alerts + + + {alerts.length === 0 ? ( +

Nothing needs attention right now.

+ ) : ( +
    + {alerts.map((a, i) => ( +
  • + + {ALERT_BADGE[a.severity].label} + + {a.text} +
  • + ))} +
+ )} +
+
+ + + + Rides & deliveries + Stock on board + Stock movement + Drivers & collections + + + + + + +
+ )} + + setEditOpen(false)} + vehicle={vehicle ?? null} + onSuccess={reload} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx index 5b838af..52c725e 100644 --- a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-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' @@ -15,6 +16,7 @@ import type { VehicleResponse } from '@/types/api' import { VehicleSheet } from './vehicle-sheet' export function VehiclesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -118,6 +120,11 @@ export function VehiclesClient() { ], rowActions: { actions: (row) => [ + { + action: 'view', + label: 'View dashboard', + onClick: (vehicle: VehicleResponse) => router.push(`/network/vehicles/${vehicle.id}`), + }, { action: 'edit', label: 'Edit vehicle', @@ -131,7 +138,7 @@ export function VehiclesClient() { }, ], }, - }), [pageSize, openEdit, deactivate]) + }), [pageSize, openEdit, deactivate, router]) return ( <> diff --git a/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx b/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx new file mode 100644 index 0000000..14aa090 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/[id]/page.tsx @@ -0,0 +1,5 @@ +import { WarehouseDashboard } from '../_components/dashboard/warehouse-dashboard' + +export default function WarehouseDashboardPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx new file mode 100644 index 0000000..1969507 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/activity-section.tsx @@ -0,0 +1,217 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { WarehouseDashboardResponse } from '@/types/api' + +import { daysAgo, fmtDate, fmtMoney, fmtNum } from './format' +import { EmptyNote, Metric, Section } from './shared' + +export function ActivitySection({ data }: { data: WarehouseDashboardResponse }) { + const { inbound, outbound, vehicles, losses, topMovers, deadStock } = data + const maxMover = Math.max(...topMovers.map(m => m.unitsOut), 1) + + return ( +
+
+
+
+ + + 0 ? 'warning' : undefined} /> + 5 ? 'danger' : undefined} + /> +
+ {inbound.recentGrns.length === 0 ? ( + No goods received in this period. + ) : ( + + + + Received + Supplier + Accepted + Rejected + Cost + + + + {inbound.recentGrns.map(g => ( + + {fmtDate(g.receivedAt)} + {g.supplierName ?? Direct receipt} + {fmtNum(g.acceptedQuantity)} + {fmtNum(g.rejectedQuantity)} + {fmtMoney(g.costValue)} + + ))} + +
+ )} +
+ +
+
+ + +
+ {outbound.recentLoadingSheets.length === 0 ? ( + No loading sheets in this period. + ) : ( + + + + Prepared + Vehicle + Status + Lines + Units + + + + {outbound.recentLoadingSheets.map(s => ( + + {fmtDate(s.populatedAt)} + {s.vehiclePlate} + + {s.status} + + {s.lines} + {fmtNum(s.units)} + + ))} + +
+ )} +
+
+ +
+
+ {vehicles.length === 0 ? ( + No vehicles are linked to this warehouse. + ) : ( + + + + Vehicle + Units on board + SKUs + + + + {vehicles.map(v => ( + + + {v.numberPlate} + {v.regNumber} + {v.isLinked && Linked} + + {fmtNum(v.units)} + {v.skus} + + ))} + +
+ )} +
+ +
+
+ + 0 ? 'warning' : undefined} /> + 0 ? 'warning' : undefined} /> + +
+ {losses.openClaims.length === 0 ? ( + No open supplier claims for goods received here. + ) : ( +
    + {losses.openClaims.map(c => ( +
  • +
    +

    {c.supplierName ?? 'Supplier'}

    +

    {c.details}

    +
    + {fmtNum(c.units)} units · {fmtDate(c.createdAt)} +
  • + ))} +
+ )} +
+
+ +
+
+ {topMovers.length === 0 ? ( + Nothing was dispatched in this period. + ) : ( +
    + {topMovers.map(m => ( +
  • +
    + {m.name} {m.sku} + {fmtNum(m.unitsOut)} units +
    +
    +
    +
    +
  • + ))} +
+ )} +
+ +
+ {deadStock.length === 0 ? ( + No idle stock found. + ) : ( + + + + Item + Qty + Cost value + Last sent out + + + + {deadStock.map(d => ( + + +

{d.name}

+

{d.sku}

+
+ {fmtNum(d.quantity)} + {fmtMoney(d.costValue)} + + {d.lastOutboundAt ? daysAgo(d.lastOutboundAt) : 'Never'} + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts new file mode 100644 index 0000000..dd77a83 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/format.ts @@ -0,0 +1,56 @@ +import type { MovementType, WarehouseType } from '@/types/api' + +export const fmtMoney = (v: number) => + `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +export const fmtNum = (v: number) => + v.toLocaleString('en-LK', { maximumFractionDigits: 2 }) + +export const fmtCompact = (v: number) => + v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 }) + +export const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +export const fmtDateTime = (v: string) => + new Date(v).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) + +export const fmtShortDay = (v: string) => + new Date(`${v}T00:00:00`).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + +/** yyyy-MM-dd in local time, the format the dashboard endpoint and date inputs use. */ +export function toIsoDate(d: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +export function daysFromToday(offset: number) { + const d = new Date() + d.setDate(d.getDate() + offset) + return toIsoDate(d) +} + +export const daysAgo = (v: string) => { + const d = Math.floor((Date.now() - new Date(v).getTime()) / 86_400_000) + return d <= 0 ? 'Today' : d === 1 ? '1 day ago' : `${d} days ago` +} + +export const MOVEMENT_LABEL: Record = { + Sale: 'Sale', + CustomerReturn: 'Customer return', + Transfer: 'Transfer', + Damage: 'Damage write-off', + Adjustment: 'Adjustment', + Load: 'Loaded to vehicle', + PurchaseReceipt: 'Purchase receipt (GRN)', + PurchaseReturn: 'Purchase return', +} + +export const WAREHOUSE_TYPE_LABEL: Record = { + DistributionCenter: 'Distribution Center', + FulfillmentCenter: 'Fulfillment Center', + CrossDocking: 'Cross Docking', + ColdStorage: 'Cold Storage', + BondedWarehouse: 'Bonded Warehouse', + AutomatedWarehouse: 'Automated Warehouse', +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx new file mode 100644 index 0000000..372e188 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/movement-section.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { WarehouseDashboardResponse, WarehouseDashboardMovementTypeRow, WarehouseMovementDirection } from '@/types/api' + +import { MOVEMENT_LABEL, fmtCompact, fmtDateTime, fmtNum, fmtShortDay } from './format' +import { EmptyNote, Section } from './shared' + +const flowConfig = { + in: { label: 'Units in', color: 'var(--chart-2)' }, + out: { label: 'Units out', color: 'var(--chart-1)' }, +} satisfies ChartConfig + +type DirectionFilter = 'all' | WarehouseMovementDirection + +function TypeBreakdown({ title, rows }: { title: string; rows: WarehouseDashboardMovementTypeRow[] }) { + const total = rows.reduce((sum, r) => sum + r.units, 0) + return ( +
+ {rows.length === 0 ? ( + No movements in this period. + ) : ( +
    + {rows.map(r => ( +
  • +
    + {MOVEMENT_LABEL[r.type]} + + {fmtNum(r.units)} units · {r.count} {r.count === 1 ? 'entry' : 'entries'} + +
    +
    +
    0 ? (r.units / total) * 100 : 0}%`, + background: r.direction === 'In' ? 'var(--chart-2)' : 'var(--chart-1)', + }} + /> +
    +
  • + ))} +
+ )} +
+ ) +} + +export function MovementSection({ data }: { data: WarehouseDashboardResponse }) { + const [direction, setDirection] = useState('all') + + const inbound = useMemo(() => data.movementByType.filter(r => r.direction === 'In'), [data.movementByType]) + const outbound = useMemo(() => data.movementByType.filter(r => r.direction === 'Out'), [data.movementByType]) + const totalEntries = data.movementByType.reduce((sum, r) => sum + r.count, 0) + const ledger = direction === 'all' ? data.ledger : data.ledger.filter(r => r.direction === direction) + + return ( +
+
+ + + + + + fmtShortDay(String(v))} />} /> + + + } /> + + +
+ +
+ + +
+ +
+
+ {(['all', 'In', 'Out'] as const).map(d => ( + + ))} +
+ + {ledger.length === 0 ? ( + No movements to show. + ) : ( +
+ + + + When + Item + Type + Qty + From → To + Reference + By + + + + {ledger.map(r => ( + + {fmtDateTime(r.createdAt)} + +

{r.itemName}

+

{r.sku}

+
+ +
+ {r.direction} + {MOVEMENT_LABEL[r.type]} +
+ {r.reasonCode &&

{r.reasonCode}

} +
+ + {r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)} + + {r.fromName} → {r.toName} + + {r.referenceKind ? `${r.referenceKind} · ${r.referenceId?.slice(0, 8)}` : '—'} + + {r.createdByName} +
+ ))} +
+
+
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx new file mode 100644 index 0000000..8018a68 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/shared.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +export function Metric({ + label, + value, + sub, + tone, +}: { + label: string + value: string + sub?: string + tone?: 'danger' | 'warning' +}) { + const valueClass = + tone === 'danger' ? 'text-destructive' : tone === 'warning' ? 'text-amber-600 dark:text-amber-400' : '' + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ) +} + +export function Section({ + title, + description, + href, + hrefLabel, + children, +}: { + title: string + description?: string + href?: string + hrefLabel?: string + children: React.ReactNode +}) { + return ( + + +
+
+ {title} + {description && {description}} +
+ {href && ( + + {hrefLabel ?? 'View all'} + + )} +
+
+ {children} +
+ ) +} + +export function EmptyNote({ children }: { children: React.ReactNode }) { + return

{children}

+} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx new file mode 100644 index 0000000..65e9ef7 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/stock-section.tsx @@ -0,0 +1,226 @@ +'use client' + +import { Fragment, useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { StockAdjustDialog } from '@/app/(dashboard)/stock/_components/stock-adjust-dialog' +import type { + StockBalanceResponse, + WarehouseDashboardResponse, + WarehouseDashboardStockRow, + WarehouseStockStatus, +} from '@/types/api' + +import { daysAgo, fmtCompact, fmtDate, fmtMoney, fmtNum } from './format' +import { EmptyNote, Section } from './shared' + +const PAGE_SIZE = 25 +const ALL = 'all' + +const categoryConfig = { value: { label: 'Stock value', color: 'var(--chart-2)' } } satisfies ChartConfig + +const STATUS_BADGE: Record = { + OK: { label: 'In stock', variant: 'secondary' }, + Low: { label: 'Low', variant: 'destructive' }, + Out: { label: 'Out of stock', variant: 'destructive' }, +} + +export function StockSection({ + data, + onChanged, +}: { + data: WarehouseDashboardResponse + onChanged: () => void +}) { + const [search, setSearch] = useState('') + const [category, setCategory] = useState(ALL) + const [status, setStatus] = useState(ALL) + const [visible, setVisible] = useState(PAGE_SIZE) + const [expanded, setExpanded] = useState(null) + const [adjusting, setAdjusting] = useState(null) + + const categories = useMemo(() => [...new Set(data.stock.map(r => r.category))].sort(), [data.stock]) + + const rows = useMemo(() => { + const q = search.trim().toLowerCase() + return data.stock.filter(r => + (category === ALL || r.category === category) && + (status === ALL || r.status === status) && + (!q || r.name.toLowerCase().includes(q) || r.sku.toLowerCase().includes(q)), + ) + }, [data.stock, search, category, status]) + + function openAdjust(row: WarehouseDashboardStockRow) { + setAdjusting({ + itemId: row.itemId, + itemName: row.name, + locationId: data.warehouse.id, + locationType: 'Warehouse', + quantity: row.quantity, + updatedAt: row.updatedAt, + locationName: data.warehouse.name, + }) + } + + return ( +
+
+
+
+ {data.stockByCategory.length === 0 ? ( + No stock on hand. + ) : ( + + + + + + fmtMoney(Number(v))} />} /> + + + + )} +
+
+ +
+ {data.expiringBatches.length === 0 ? ( + No batches at risk. + ) : ( +
    + {data.expiringBatches.map(b => ( +
  • +
    +

    {b.itemName}

    +

    + Batch {b.batchNumber ?? '—'} · {fmtNum(b.quantity)} units · {fmtDate(b.expiryDate)} +

    +
    + + {b.status === 'Expired' ? 'Expired' : 'Expiring'} + +
  • + ))} +
+ )} +
+
+ +
+
+ { setSearch(e.target.value); setVisible(PAGE_SIZE) }} + placeholder="Search item or SKU" + className="w-56" + /> + + +
+ + {rows.length === 0 ? ( + No items match the current filters. + ) : ( +
+ + + + Item + Category + On hand + Reorder at + Status + Cost value + Sale value + Updated + + + + + {rows.slice(0, visible).map(r => { + const badge = STATUS_BADGE[r.status] + const hasBatches = r.batches.length > 0 + return ( + + setExpanded(expanded === r.itemId ? null : r.itemId) : undefined} + > + +

{r.name}

+

+ {r.sku}{r.brand ? ` · ${r.brand}` : ''}{hasBatches ? ` · ${r.batches.length} batch${r.batches.length > 1 ? 'es' : ''}` : ''} +

+
+ {r.category} + {fmtNum(r.quantity)} {r.uom} + {r.reorderLevel != null ? fmtNum(r.reorderLevel) : '—'} + {badge.label} + {fmtMoney(r.costValue)} + {fmtMoney(r.saleValue)} + {daysAgo(r.updatedAt)} + + {!hasBatches && ( + + )} + +
+ {expanded === r.itemId && r.batches.map(b => ( + + + Batch {b.batchNumber ?? '—'} + + {fmtNum(b.quantity)} {r.uom} + + {b.expiryDate ? `Expires ${fmtDate(b.expiryDate)}` : 'No expiry date'} + + + ))} +
+ ) + })} +
+
+
+ )} + + {rows.length > visible && ( +
+ +
+ )} +
+ + setAdjusting(null)} + onSuccess={() => { setAdjusting(null); onChanged() }} + balance={adjusting} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx new file mode 100644 index 0000000..a5c46ee --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/dashboard/warehouse-dashboard.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { toast } from '@/components/ui/toast' +import { warehouses as warehousesApi, getApiError } from '@/lib/api/index' +import type { WarehouseDashboardResponse } from '@/types/api' + +import { WarehouseSheet } from '../warehouse-sheet' +import { ActivitySection } from './activity-section' +import { WAREHOUSE_TYPE_LABEL, daysFromToday, fmtDate, fmtMoney, fmtNum } from './format' +import { MovementSection } from './movement-section' +import { Metric } from './shared' +import { StockSection } from './stock-section' + +const PRESETS = [ + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, +] + +type Alert = { severity: 'danger' | 'warning' | 'info'; text: string } + +function buildAlerts(d: WarehouseDashboardResponse): Alert[] { + const alerts: Alert[] = [] + const { kpis, losses, inbound, outbound } = d + + if (kpis.outOfStockCount > 0) + alerts.push({ severity: 'danger', text: `${kpis.outOfStockCount} item${kpis.outOfStockCount > 1 ? 's' : ''} out of stock` }) + + const low = d.stock.filter(r => r.status === 'Low') + if (low.length > 0) { + const names = low.slice(0, 3).map(r => `${r.name} (${fmtNum(r.quantity)}/${fmtNum(r.reorderLevel ?? 0)})`).join(', ') + alerts.push({ severity: 'warning', text: `${low.length} item${low.length > 1 ? 's' : ''} below reorder level: ${names}${low.length > 3 ? '…' : ''}` }) + } + + if (kpis.expiredCount > 0) + alerts.push({ severity: 'danger', text: `${kpis.expiredCount} expired batch${kpis.expiredCount > 1 ? 'es' : ''} still in stock` }) + if (kpis.nearExpiryCount > 0) + alerts.push({ severity: 'warning', text: `${kpis.nearExpiryCount} batch${kpis.nearExpiryCount > 1 ? 'es' : ''} expiring within 30 days` }) + + if (losses.openClaimCount > 0) + alerts.push({ severity: 'warning', text: `${losses.openClaimCount} open supplier claim${losses.openClaimCount > 1 ? 's' : ''} on goods received here` }) + if (inbound.rejectionRatePercent > 5) + alerts.push({ severity: 'warning', text: `GRN rejection rate is ${inbound.rejectionRatePercent}% this period` }) + if (losses.negativeAdjustmentUnits > 0) + alerts.push({ severity: 'warning', text: `${fmtNum(losses.negativeAdjustmentUnits)} units adjusted down this period` }) + if (losses.damageUnits > 0) + alerts.push({ severity: 'warning', text: `${fmtNum(losses.damageUnits)} units written off as damaged this period` }) + if (outbound.pendingDispatchUnits > 0) + alerts.push({ severity: 'info', text: `${fmtNum(outbound.pendingDispatchUnits)} units reserved on planned loading sheets` }) + + return alerts +} + +const ALERT_BADGE = { + danger: { label: 'Critical', variant: 'destructive' }, + warning: { label: 'Attention', variant: 'outline' }, + info: { label: 'Info', variant: 'secondary' }, +} as const + +export function WarehouseDashboard() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + + const [from, setFrom] = useState(() => daysFromToday(-29)) + const [to, setTo] = useState(() => daysFromToday(0)) + const [data, setData] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + const [loadedKey, setLoadedKey] = useState('') + const [editOpen, setEditOpen] = useState(false) + + const requestKey = `${id}|${from}|${to}|${reloadKey}` + const loading = loadedKey !== requestKey + const reload = () => setReloadKey(k => k + 1) + + useEffect(() => { + let cancelled = false + warehousesApi.dashboard(id, { from, to }) + .then(res => { if (!cancelled) setData(res) }) + .catch(err => { + if (!cancelled) toast.add({ title: 'Failed to load warehouse dashboard', description: getApiError(err), type: 'error' }) + }) + .finally(() => { if (!cancelled) setLoadedKey(requestKey) }) + return () => { cancelled = true } + }, [id, from, to, requestKey]) + + const alerts = useMemo(() => (data ? buildAlerts(data) : []), [data]) + + function applyPreset(days: number) { + setFrom(daysFromToday(-(days - 1))) + setTo(daysFromToday(0)) + } + + const warehouse = data?.warehouse + const k = data?.kpis + + return ( +
+ {/* Header */} +
+
+ +
+ {!warehouse ? ( + + ) : ( +
+

{warehouse.name}

+ {WAREHOUSE_TYPE_LABEL[warehouse.type] ?? warehouse.type} + + {warehouse.isActive ? 'Active' : 'Inactive'} + +
+ )} +

+ {warehouse + ? `${warehouse.location} · Created ${fmtDate(warehouse.createdAt)}` + : 'Warehouse overview'} + {data && data.linkedVehicles.length > 0 && ( + <> · Vehicles: {data.linkedVehicles.map(v => v.numberPlate).join(', ')} + )} +

+
+
+ + {warehouse && ( + + )} +
+ + {/* Period */} +
+ Activity period + {PRESETS.map(p => ( + + ))} + e.target.value && setFrom(e.target.value)} className="w-40" /> + to + e.target.value && setTo(e.target.value)} className="w-40" /> +
+ + {!data && loading && ( +
+
+ {Array.from({ length: 6 }).map((_, i) => )} +
+ +
+ )} + + {data && k && ( +
+ {/* KPIs */} +
+ + + 0 ? 'danger' : k.lowStockCount > 0 ? 'warning' : undefined} + sub="items" + /> + 0 ? 'danger' : k.nearExpiryCount > 0 ? 'warning' : undefined} + sub="near / expired batches" + /> + + +
+ + {/* Alerts */} + + + Alerts + + + {alerts.length === 0 ? ( +

Nothing needs attention right now.

+ ) : ( +
    + {alerts.map((a, i) => ( +
  • + + {ALERT_BADGE[a.severity].label} + + {a.text} +
  • + ))} +
+ )} +
+
+ + + + Stock on hand + Stock movement + Module activity + + + + + +
+ )} + + setEditOpen(false)} + warehouse={warehouse ?? null} + onSuccess={reload} + /> +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx index f6e88a6..a70857c 100644 --- a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-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' @@ -15,6 +16,7 @@ import type { WarehouseResponse } from '@/types/api' import { WarehouseSheet } from './warehouse-sheet' export function WarehousesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) @@ -118,6 +120,11 @@ export function WarehousesClient() { ], rowActions: { actions: () => [ + { + action: 'view', + label: 'View dashboard', + onClick: (warehouse: WarehouseResponse) => router.push(`/network/warehouses/${warehouse.id}`), + }, { action: 'edit', label: 'Edit warehouse', @@ -137,7 +144,7 @@ export function WarehousesClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, openEdit, toggleActive, router]) return ( <> diff --git a/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx index 369d090..2e3ff30 100644 --- a/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx +++ b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx @@ -3,12 +3,14 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { DataTable } from '@/components/shared/data-table' +import { ExportButtons } from '@/components/shared/export-buttons' import type { TableConfig } from '@/components/shared/data-table' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { toast } from '@/components/ui/toast' import { getApiError, getPaginatedItems } from '@/lib/api/index' +import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export' import { productReports, type ProductReportSummary, @@ -71,6 +73,44 @@ const returnsColumns: TableConfig['columns'] = [ }, ] +const STOCK_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Brand', value: r => r.brand ?? '' }, + { header: 'Category', value: r => r.category }, + { header: 'UOM', value: r => r.uom }, + { header: 'Warehouse', value: r => r.warehouseQty, numeric: true }, + { header: 'Vehicles', value: r => r.vehicleQty, numeric: true }, + { header: 'Total', value: r => r.totalQty, numeric: true }, + { header: 'Stock Value', value: r => r.stockValue, numeric: true }, + { header: 'Low Stock', value: r => (r.isLowStock ? 'Yes' : 'No') }, +] + +const SALES_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Orders', value: r => r.orders, numeric: true }, + { header: 'Stores', value: r => r.stores, numeric: true }, + { header: 'Qty Ordered', value: r => r.qtyOrdered, numeric: true }, + { header: 'Qty Fulfilled', value: r => r.qtyFulfilled, numeric: true }, + { header: 'Sales', value: r => r.salesValue, numeric: true }, + { header: 'Margin', value: r => r.margin, numeric: true }, + { header: 'Margin %', value: r => r.marginPercent, numeric: true }, +] + +const RETURNS_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Returned Qty', value: r => r.returnedQty, numeric: true }, + { header: 'Returned Value', value: r => r.returnedValue, numeric: true }, + { header: 'Sold Qty', value: r => r.soldQty, numeric: true }, + { header: 'Return %', value: r => r.returnPercent, numeric: true }, + { + header: 'By Reason', + value: r => Object.entries(r.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', '), + }, +] + export function ProductReportClient() { const [tab, setTab] = useState('stock') const [search, setSearch] = useState('') @@ -128,6 +168,30 @@ export function ProductReportClient() { setTotal(0) } + async function handleExport(format: 'excel' | 'pdf') { + const filters = (p: number, s: number) => ({ + page: p, pageSize: s, search: search || undefined, from: from || undefined, to: to || undefined, + }) + const range = `${from || 'start'} to ${to || 'today'}` + + if (tab === 'stock') { + const rows = await fetchAllPages((p, s) => + productReports.stock({ ...filters(p, s), lowStockOnly }).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-stock', STOCK_EXPORT, rows) + else await exportPdf('product-stock', 'Product Report: Stock', STOCK_EXPORT, rows, lowStockOnly ? 'Low stock only' : undefined) + } else if (tab === 'sales') { + const rows = await fetchAllPages((p, s) => + productReports.sales(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-sales', SALES_EXPORT, rows) + else await exportPdf('product-sales', 'Product Report: Sales', SALES_EXPORT, rows, range) + } else { + const rows = await fetchAllPages((p, s) => + productReports.returns(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total }))) + if (format === 'excel') await exportExcel('product-returns', RETURNS_EXPORT, rows) + else await exportPdf('product-returns', 'Product Report: Returns', RETURNS_EXPORT, rows, range) + } + } + const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } } const stockConfig = useMemo>( @@ -197,6 +261,9 @@ export function ProductReportClient() { Low stock only )} +
+ +
{tab === 'stock' && ( diff --git a/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx b/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx new file mode 100644 index 0000000..3506c83 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/stock/_components/stock-report-client.tsx @@ -0,0 +1,331 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import { ExportButtons } from '@/components/shared/export-buttons' +import type { TableConfig } from '@/components/shared/data-table' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { getApiError, getPaginatedItems } from '@/lib/api/index' +import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export' +import { categories } from '@/lib/api/catalogue' +import { vehicles, warehouses } from '@/lib/api/master' +import { + stockReports, + type StockExpiryRow, + type StockLedgerRow, + type StockOverview, +} from '@/lib/api/reports' + +type Tab = 'ledger' | 'expiry' + +const TABS: { key: Tab; label: string }[] = [ + { key: 'ledger', label: 'Stock Ledger' }, + { key: 'expiry', label: 'Expiry' }, +] + +const ALL = 'all' +const EXPIRY_WINDOWS = [7, 30, 60, 90] + +const num = (v: number | null | undefined) => + v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 }) + +const fmtDate = (v: string) => new Date(v).toLocaleDateString('en-GB') + +const STATUS_CLASS: Record = { + OK: 'text-emerald-600', + Low: 'text-amber-600', + Out: 'text-red-600', +} + +const ledgerColumns: TableConfig['columns'] = [ + { key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 }, + { key: 'category', title: 'Category', fields: ['category'], width: 140 }, + { key: 'uom', title: 'UOM', fields: ['uom'], width: 70 }, + { key: 'openingQty', title: 'Opening', fields: [{ key: 'openingQty', transform: v => num(v as number) }], width: 100 }, + { key: 'qtyIn', title: 'In', fields: [{ key: 'qtyIn', transform: v => num(v as number) }], width: 90 }, + { key: 'qtyOut', title: 'Out', fields: [{ key: 'qtyOut', transform: v => num(v as number) }], width: 90 }, + { key: 'closingQty', title: 'Closing', fields: [{ key: 'closingQty', transform: v => num(v as number) }], width: 100 }, + { key: 'currentQty', title: 'On Hand', fields: [{ key: 'currentQty', transform: v => num(v as number) }], width: 100 }, + { key: 'stockValue', title: 'Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 110 }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 90, + cellRender: row => {row.status}, + }, +] + +const expiryColumns: TableConfig['columns'] = [ + { key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 }, + { key: 'batchNumber', title: 'Batch', fields: [{ key: 'batchNumber', transform: v => (v as string | null) ?? '—' }], width: 130 }, + { key: 'locationName', title: 'Location', fields: [{ key: 'locationName', primary: true }, { key: 'locationType' }], width: 150 }, + { key: 'quantity', title: 'Qty', fields: [{ key: 'quantity', transform: v => num(v as number) }], width: 90 }, + { key: 'value', title: 'Value', fields: [{ key: 'value', transform: v => num(v as number) }], width: 110 }, + { key: 'expiryDate', title: 'Expiry', fields: [{ key: 'expiryDate', transform: v => fmtDate(v as string) }], width: 110 }, + { + key: 'daysLeft', + title: 'Days Left', + fields: ['daysLeft'], + width: 110, + cellRender: row => ( + + {row.daysLeft < 0 ? `Expired ${-row.daysLeft}d ago` : `${row.daysLeft}d`} + + ), + }, +] + +const LEDGER_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Category', value: r => r.category }, + { header: 'UOM', value: r => r.uom }, + { header: 'Opening', value: r => r.openingQty, numeric: true }, + { header: 'In', value: r => r.qtyIn, numeric: true }, + { header: 'Out', value: r => r.qtyOut, numeric: true }, + { header: 'Closing', value: r => r.closingQty, numeric: true }, + { header: 'On Hand', value: r => r.currentQty, numeric: true }, + { header: 'Value', value: r => r.stockValue, numeric: true }, + { header: 'Status', value: r => r.status }, +] + +const EXPIRY_EXPORT: ExportColumn[] = [ + { header: 'SKU', value: r => r.sku }, + { header: 'Item', value: r => r.name }, + { header: 'Batch', value: r => r.batchNumber ?? '' }, + { header: 'Location', value: r => r.locationName }, + { header: 'Qty', value: r => r.quantity, numeric: true }, + { header: 'Value', value: r => r.value, numeric: true }, + { header: 'Expiry', value: r => fmtDate(r.expiryDate) }, + { header: 'Days Left', value: r => r.daysLeft, numeric: true }, +] + +export function StockReportClient() { + const [tab, setTab] = useState('ledger') + const [search, setSearch] = useState('') + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [locationId, setLocationId] = useState(ALL) + const [categoryId, setCategoryId] = useState(ALL) + const [status, setStatus] = useState(ALL) + const [expiryDays, setExpiryDays] = useState(30) + + const [locationOptions, setLocationOptions] = useState<{ id: string; label: string }[]>([]) + const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]) + const [overview, setOverview] = useState(null) + const [ledgerData, setLedgerData] = useState([]) + const [expiryData, setExpiryData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + useEffect(() => { + Promise.all([warehouses.list({ pageSize: 200 }), vehicles.list({ pageSize: 200 }), categories.list()]) + .then(([w, v, c]) => { + setLocationOptions([ + ...getPaginatedItems(w).map(x => ({ id: x.id, label: `Warehouse · ${x.name}` })), + ...getPaginatedItems(v).map(x => ({ id: x.id, label: `Vehicle · ${x.numberPlate}` })), + ]) + setCategoryOptions(c.map(x => ({ id: x.id, name: x.name }))) + }) + .catch(err => toast.add({ title: 'Failed to load filters', description: getApiError(err), type: 'error' })) + }, []) + + const location = locationId === ALL ? undefined : locationId + + const ledgerParams = useCallback( + (p: number, s: number) => ({ + page: p, + pageSize: s, + search: search || undefined, + locationId: location, + from: from || undefined, + to: to || undefined, + categoryId: categoryId === ALL ? undefined : categoryId, + status: status === ALL ? undefined : (status as 'low' | 'out'), + }), + [search, location, from, to, categoryId, status], + ) + + const expiryParams = useCallback( + (p: number, s: number) => ({ page: p, pageSize: s, search: search || undefined, locationId: location, days: expiryDays }), + [search, location, expiryDays], + ) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + if (tab === 'ledger') { + const res = await stockReports.ledger(ledgerParams(page, pageSize)) + setLedgerData(getPaginatedItems(res)) + setTotal(res.total) + } else { + const res = await stockReports.expiry(expiryParams(page, pageSize)) + setExpiryData(getPaginatedItems(res)) + setTotal(res.total) + } + } catch (err) { + toast.add({ title: 'Failed to load stock report', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, [tab, page, pageSize, ledgerParams, expiryParams]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + useEffect(() => { + stockReports + .overview({ locationId: location, expiryDays }) + .then(setOverview) + .catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' })) + }, [location, expiryDays]) + + function changeTab(next: Tab) { + setTab(next) + setPage(1) + setTotal(0) + } + + async function handleExport(format: 'excel' | 'pdf') { + const locationLabel = locationOptions.find(o => o.id === locationId)?.label ?? 'All locations' + if (tab === 'ledger') { + const rows = await fetchAllPages((p, s) => + stockReports.ledger(ledgerParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })), + ) + const range = `${from || 'last 30 days'} to ${to || 'today'}` + if (format === 'excel') await exportExcel('stock-ledger', LEDGER_EXPORT, rows) + else await exportPdf('stock-ledger', 'Stock Ledger', LEDGER_EXPORT, rows, `${locationLabel} · ${range}`) + } else { + const rows = await fetchAllPages((p, s) => + stockReports.expiry(expiryParams(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })), + ) + if (format === 'excel') await exportExcel('stock-expiry', EXPIRY_EXPORT, rows) + else await exportPdf('stock-expiry', 'Stock Expiry', EXPIRY_EXPORT, rows, `${locationLabel} · within ${expiryDays} days`) + } + } + + const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } } + + const ledgerConfig = useMemo>( + () => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: ledgerColumns }), + [pageSize], + ) + const expiryConfig = useMemo>( + () => ({ + ...common, + rowKey: r => `${r.batchId}-${r.locationId}`, + emptyMessage: 'No batches expiring in this window.', + columns: expiryColumns, + }), + [pageSize], + ) + + const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => { + setPage(p.page) + setPageSize(p.pageSize) + } + + const cards: [string, string][] = overview + ? [ + ['Products in stock', num(overview.totalSkus)], + ['Total qty', num(overview.totalQty)], + ['Stock value', num(overview.totalValue)], + ['Low stock', num(overview.lowStockCount)], + ['Out of stock', num(overview.outOfStockCount)], + [`Expiring in ${expiryDays}d`, num(overview.expiringSoonCount)], + ['Expired', num(overview.expiredCount)], + ] + : [] + + return ( + <> +
+

Stock Report

+

+ Opening, in, out and closing stock across all locations or one warehouse or vehicle, plus batches nearing expiry. +

+
+ + {overview && ( +
+ {cards.map(([label, value]) => ( + + + {label} + {value} + + + ))} +
+ )} + +
+ {TABS.map(t => ( + + ))} + + { setSearch(e.target.value); setPage(1) }} + /> + {tab === 'ledger' ? ( + <> + + + { setFrom(e.target.value); setPage(1) }} /> + { setTo(e.target.value); setPage(1) }} /> + + ) : ( + + )} +
+ +
+
+ + {tab === 'ledger' && ( + + )} + {tab === 'expiry' && ( + + )} + + ) +} diff --git a/distribution_fe/app/(dashboard)/reports/stock/page.tsx b/distribution_fe/app/(dashboard)/reports/stock/page.tsx new file mode 100644 index 0000000..64ff8b6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/stock/page.tsx @@ -0,0 +1,5 @@ +import { StockReportClient } from './_components/stock-report-client' + +export default function StockReportPage() { + return +} diff --git a/distribution_fe/components/shared/export-buttons.tsx b/distribution_fe/components/shared/export-buttons.tsx new file mode 100644 index 0000000..4280961 --- /dev/null +++ b/distribution_fe/components/shared/export-buttons.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { getApiError } from '@/lib/api/index' + +interface ExportButtonsProps { + /** Runs the export for the chosen format; fetch the full filtered data inside. */ + onExport: (format: 'excel' | 'pdf') => Promise + disabled?: boolean +} + +export function ExportButtons({ onExport, disabled }: ExportButtonsProps) { + const [busy, setBusy] = useState<'excel' | 'pdf' | null>(null) + + async function run(format: 'excel' | 'pdf') { + setBusy(format) + try { + await onExport(format) + } catch (err) { + toast.add({ title: 'Export failed', description: getApiError(err), type: 'error' }) + } finally { + setBusy(null) + } + } + + return ( + <> + + + + ) +} diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 9d86b5d..8d81d68 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -168,6 +168,7 @@ export const NAV_GROUPS: NavGroup[] = [ icon: ClipboardListIcon, items: [ { title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' }, + { title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' }, ], }, ] diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index a13bf59..fcbdf82 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -12,6 +12,8 @@ import type { RouteVisitResponse, VehicleResponse, WarehouseResponse, + WarehouseDashboardResponse, + VehicleDashboardResponse, VehicleType, WarehouseType, } from '@/types/api' @@ -199,6 +201,10 @@ export const vehicles = { get: (id: string) => api.get(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)), + dashboard: (id: string, params?: { from?: string; to?: string }) => + api.get(`/vehicles/${id}/dashboard`, { params }) + .then(r => ({ ...r.data, vehicle: normalizeVehicle(r.data.vehicle) })), + create: (body: VehicleBody) => api.post('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)), @@ -226,6 +232,9 @@ export const warehouses = { get: (id: string) => api.get(`/warehouses/${id}`).then(r => r.data), + dashboard: (id: string, params?: { from?: string; to?: string; deadStockDays?: number }) => + api.get(`/warehouses/${id}/dashboard`, { params }).then(r => r.data), + create: (body: WarehouseBody) => api.post('/warehouses', body).then(r => r.data), diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts index a3dcdd9..8d5122e 100644 --- a/distribution_fe/lib/api/reports.ts +++ b/distribution_fe/lib/api/reports.ts @@ -72,6 +72,80 @@ export interface ProductStockParams extends ProductReportParams { lowStockOnly?: boolean } +export interface StockOverview { + totalSkus: number + totalQty: number + totalValue: number + lowStockCount: number + outOfStockCount: number + expiringSoonCount: number + expiredCount: number +} + +export type StockLedgerStatus = 'OK' | 'Low' | 'Out' + +export interface StockLedgerRow { + itemId: string + sku: string + name: string + brand: string | null + category: string + uom: string + openingQty: number + qtyIn: number + qtyOut: number + closingQty: number + currentQty: number + costPrice: number | null + stockValue: number + reorderLevel: number | null + status: StockLedgerStatus +} + +export interface StockExpiryRow { + itemId: string + sku: string + name: string + batchId: string + batchNumber: string | null + expiryDate: string + daysLeft: number + quantity: number + costPrice: number | null + value: number + locationId: string + locationType: string + locationName: string +} + +export interface StockLedgerParams extends PageParams { + locationId?: string + categoryId?: string + brandId?: string + from?: string + to?: string + status?: 'low' | 'out' + activeOnly?: boolean + search?: string +} + +export interface StockExpiryParams extends PageParams { + locationId?: string + days?: number + search?: string +} + +export const stockReports = { + overview: (params?: { locationId?: string; expiryDays?: number }) => + api.get('/reports/stock/overview', { params }).then(r => r.data), + + ledger: (params?: StockLedgerParams) => + api.get>('/reports/stock/ledger', { params }).then(r => r.data), + + expiry: (params?: StockExpiryParams) => + api.get>('/reports/stock/expiry', { params }).then(r => r.data), +} + export const productReports = { stock: (params?: ProductStockParams) => api.get>('/reports/products/stock', { params }).then(r => r.data), diff --git a/distribution_fe/lib/export.ts b/distribution_fe/lib/export.ts new file mode 100644 index 0000000..3f82403 --- /dev/null +++ b/distribution_fe/lib/export.ts @@ -0,0 +1,81 @@ +export interface ExportColumn { + header: string + value: (row: T) => string | number | null | undefined + /** Right-align and format as a number in Excel. */ + numeric?: boolean +} + +const MAX_PAGE_SIZE = 200 + +/** Walks every page of a server-paged endpoint and returns all rows. */ +export async function fetchAllPages( + fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>, +): Promise { + const rows: T[] = [] + for (let page = 1; ; page++) { + const res = await fetchPage(page, MAX_PAGE_SIZE) + rows.push(...res.items) + if (res.items.length === 0 || rows.length >= res.total) return rows + } +} + +function stamp() { + return new Date().toISOString().slice(0, 10) +} + +function download(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export async function exportExcel(name: string, columns: ExportColumn[], rows: T[]) { + const { Workbook } = await import('exceljs') + const wb = new Workbook() + const ws = wb.addWorksheet(name.slice(0, 31)) + + ws.columns = columns.map(c => ({ header: c.header, width: Math.max(c.header.length + 4, 14) })) + ws.getRow(1).font = { bold: true } + ws.views = [{ state: 'frozen', ySplit: 1 }] + + rows.forEach(r => ws.addRow(columns.map(c => c.value(r) ?? ''))) + columns.forEach((c, i) => { + if (c.numeric) { + const col = ws.getColumn(i + 1) + col.numFmt = '#,##0.##' + col.alignment = { horizontal: 'right' } + } + }) + + const buffer = await wb.xlsx.writeBuffer() + download( + new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), + `${name}-${stamp()}.xlsx`, + ) +} + +export async function exportPdf(name: string, title: string, columns: ExportColumn[], rows: T[], subtitle?: string) { + const [{ jsPDF }, { default: autoTable }] = await Promise.all([import('jspdf'), import('jspdf-autotable')]) + const doc = new jsPDF({ orientation: columns.length > 6 ? 'landscape' : 'portrait', unit: 'pt', format: 'a4' }) + + doc.setFontSize(14) + doc.text(title, 40, 40) + doc.setFontSize(9) + doc.text(subtitle ? `${subtitle} · Generated ${stamp()}` : `Generated ${stamp()}`, 40, 56) + + autoTable(doc, { + startY: 68, + head: [columns.map(c => c.header)], + body: rows.map(r => columns.map(c => String(c.value(r) ?? ''))), + styles: { fontSize: 8 }, + headStyles: { fillColor: [55, 65, 81] }, + columnStyles: Object.fromEntries( + columns.map((c, i) => [i, c.numeric ? { halign: 'right' as const } : {}]), + ), + }) + + doc.save(`${name}-${stamp()}.pdf`) +} diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 22dc773..a9fe150 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -185,6 +185,97 @@ export interface VehicleResponse { createdAt: string } +// ─── Vehicle dashboard ──────────────────────────────────────────────────────── + +export interface VehicleDashboardKpis { + rides: number + closedRides: number + cancelledRides: number + distanceKm: number + deliveries: number + deliveredCount: number + failedCount: number + pendingCount: number + successRatePercent: number + deliveredValue: number + collections: number + stockOnBoardUnits: number + stockOnBoardValue: number + unitsLoaded: number + unitsSold: number +} + +export interface VehicleDashboardStockRow { + itemId: string + sku: string + name: string + category: string + uom: string + quantity: number + value: number + updatedAt: string +} + +export interface VehicleDashboardRide { + id: string + status: RideStatus + driverName: string + startedAt: string | null + closedAt: string | null + createdAt: string + distanceKm: number + tasks: number + delivered: number + failed: number + cancelType: CancelRequestType | null + cancelReason: string | null +} + +export interface VehicleDashboardDriver { + id: string + name: string + licenseNumber: string + licenseExpiry: string + medicalClearanceExpiry: string + isActive: boolean + rides: number + distanceKm: number + lastRideAt: string | null +} + +export interface VehicleDashboardResponse { + vehicle: VehicleResponse + defaultWarehouse: { id: string; name: string } | null + mobileWarehouse: { id: string; name: string } | null + currentRide: { + id: string + driverName: string + startedAt: string | null + meterStart: number + tasks: number + delivered: number + failed: number + } | null + period: { from: string; to: string } + kpis: VehicleDashboardKpis + stockOnBoard: VehicleDashboardStockRow[] + rides: VehicleDashboardRide[] + dailyDeliveries: { date: string; delivered: number; failed: number }[] + dailyMovement: { date: string; in: number; out: number }[] + movementByType: WarehouseDashboardMovementTypeRow[] + ledger: WarehouseDashboardLedgerRow[] + cancelReasons: { type: CancelRequestType; count: number }[] + collectionsByMethod: { method: PaymentMethod; amount: number; count: number }[] + returns: { + returnCount: number + creditValue: number + returnedUnits: number + customerReturnUnitsOnVehicle: number + } + topStores: { storeId: string; storeName: string; deliveries: number; value: number }[] + drivers: VehicleDashboardDriver[] +} + export interface WarehouseResponse { id: string name: string @@ -195,6 +286,174 @@ export interface WarehouseResponse { createdAt: string } +// ─── Warehouse dashboard ────────────────────────────────────────────────────── + +export type WarehouseStockStatus = 'OK' | 'Low' | 'Out' +export type WarehouseMovementDirection = 'In' | 'Out' + +export interface WarehouseDashboardKpis { + stockValueCost: number + stockValueSale: number + totalUnits: number + skuCount: number + lowStockCount: number + outOfStockCount: number + nearExpiryCount: number + expiredCount: number + unitsIn: number + unitsOut: number +} + +export interface WarehouseDashboardBatch { + batchId: string + batchNumber: string | null + expiryDate: string | null + quantity: number +} + +export interface WarehouseDashboardStockRow { + itemId: string + sku: string + name: string + category: string + brand: string | null + uom: string + quantity: number + reorderLevel: number | null + status: WarehouseStockStatus + costValue: number + saleValue: number + updatedAt: string + batches: WarehouseDashboardBatch[] +} + +export interface WarehouseDashboardCategoryStock { + category: string + units: number + value: number +} + +export interface WarehouseDashboardExpiringBatch { + itemId: string + itemName: string + batchNumber: string | null + expiryDate: string + quantity: number + status: 'NearExpiry' | 'Expired' +} + +export interface WarehouseDashboardDailyPoint { + date: string + in: number + out: number +} + +export interface WarehouseDashboardMovementTypeRow { + type: MovementType + direction: WarehouseMovementDirection + units: number + count: number +} + +export interface WarehouseDashboardLedgerRow { + id: string + createdAt: string + itemId: string + itemName: string + sku: string + type: MovementType + direction: WarehouseMovementDirection + quantity: number + fromName: string + toName: string + referenceId: string | null + referenceKind: string | null + reasonCode: string | null + createdByName: string +} + +export interface WarehouseDashboardGrnRow { + id: string + receivedAt: string + purchaseOrderId: string | null + supplierName: string | null + acceptedQuantity: number + rejectedQuantity: number + costValue: number +} + +export interface WarehouseDashboardLoadingSheetRow { + id: string + rideId: string + vehiclePlate: string + status: LoadingSheetStatus + populatedAt: string + confirmedAt: string | null + lines: number + units: number +} + +export interface WarehouseDashboardVehicle { + vehicleId: string + regNumber: string + numberPlate: string + isLinked: boolean + units: number + skus: number +} + +export interface WarehouseDashboardClaimRow { + id: string + createdAt: string + supplierName: string | null + details: string + units: number +} + +export interface WarehouseDashboardResponse { + warehouse: WarehouseResponse + linkedVehicles: { id: string; regNumber: string; numberPlate: string; isPrimary: boolean }[] + period: { from: string; to: string } + deadStockDays: number + kpis: WarehouseDashboardKpis + stock: WarehouseDashboardStockRow[] + stockByCategory: WarehouseDashboardCategoryStock[] + expiringBatches: WarehouseDashboardExpiringBatch[] + dailyMovement: WarehouseDashboardDailyPoint[] + movementByType: WarehouseDashboardMovementTypeRow[] + ledger: WarehouseDashboardLedgerRow[] + inbound: { + grnCount: number + acceptedUnits: number + rejectedUnits: number + rejectionRatePercent: number + recentGrns: WarehouseDashboardGrnRow[] + } + outbound: { + unitsDispatched: number + pendingDispatchUnits: number + recentLoadingSheets: WarehouseDashboardLoadingSheetRow[] + } + vehicles: WarehouseDashboardVehicle[] + losses: { + purchaseReturnUnits: number + damageUnits: number + negativeAdjustmentUnits: number + positiveAdjustmentUnits: number + openClaimCount: number + openClaims: WarehouseDashboardClaimRow[] + } + topMovers: { itemId: string; sku: string; name: string; unitsOut: number }[] + deadStock: { + itemId: string + sku: string + name: string + quantity: number + costValue: number + lastOutboundAt: string | null + }[] +} + // ─── Stores ─────────────────────────────────────────────────────────────────── export interface StoreResponse {