Merge pull request 'Refactor/main flow' (#60) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m3s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m51s

Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2026-10-16 10:25:08 +00:00
53 changed files with 4655 additions and 346 deletions
@@ -1,11 +1,12 @@
using Distribution.Models; using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide; namespace Distribution.Features.DeliveryRide;
[ApiController] [ApiController]
[Route("api")] [Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
{ {
[HttpPost("rides/{rideId:guid}/tasks/generate")] [HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct) public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
@@ -61,7 +62,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
{ {
try try
{ {
var result = await service.ConfirmAsync(id, ct); var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -12,7 +12,7 @@ public class DeliveryTaskService(
IRideRepository rideRepo, IRideRepository rideRepo,
IShopPORepository shopPORepo, IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo, ILoadingSheetRepository loadingSheetRepo,
IStockRepository stockRepo, StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static DeliveryTaskResponse ToResponse(DeliveryTask t) => private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
@@ -134,7 +134,7 @@ public class DeliveryTaskService(
return ToResponse(saved!); return ToResponse(saved!);
} }
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default) public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, Guid userId, CancellationToken ct = default)
{ {
var task = await repo.GetByIdAsync(taskId) var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found"); ?? 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)) 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(); var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
if (availableQuantity < item.QuantityFulfilled) await mover.MoveAsync(item.FK_ItemId, itemName,
throw new InvalidOperationException( vehicleId, LocationType.Vehicle,
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}"); StockService.ExternalLocationId, LocationType.External,
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
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
});
} }
repo.Update(task); repo.Update(task);
@@ -8,7 +8,7 @@ namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService( public class LoadingSheetService(
ILoadingSheetRepository repo, ILoadingSheetRepository repo,
IRideRepository rideRepo, IRideRepository rideRepo,
IStockRepository stockRepo, StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, Guid? routeId, Guid? regionId, int page, int pageSize) public async Task<PagedResponse<LoadingSheetResponse>> 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<LoadingSheetItem> items, Guid userId) private async Task ApplyLoadMovementsAsync(Guid sheetId, Guid vehicleId, List<LoadingSheetItem> 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<Guid, StockBalance>();
foreach (var item in items.Where(i => i.QuantityDispatched > 0)) foreach (var item in items.Where(i => i.QuantityDispatched > 0))
{ {
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId) var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
?? throw new InvalidOperationException(
$"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'");
if (warehouseBalance.Quantity < item.QuantityDispatched) await mover.MoveAsync(item.FK_ItemId, label,
throw new InvalidOperationException( item.FK_WarehouseId, LocationType.Warehouse,
$"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " + vehicleId, LocationType.Vehicle,
$"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})"); item.QuantityDispatched, MovementType.Load, sheetId, userId);
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
});
} }
} }
@@ -10,7 +10,7 @@ public class PurchaseReturnService(
IPurchaseReturnRepository repo, IPurchaseReturnRepository repo,
IPoRepository poRepo, IPoRepository poRepo,
IGrnRepository goodsReceiptRepo, IGrnRepository goodsReceiptRepo,
IStockRepository stockRepo, StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) => private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
@@ -89,29 +89,12 @@ public class PurchaseReturnService(
foreach (var line in purchaseReturn.Items) foreach (var line in purchaseReturn.Items)
{ {
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId) var itemName = po.Items.FirstOrDefault(i => i.FK_ItemId == line.FK_ItemId)?.Item?.Name ?? line.FK_ItemId.ToString();
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
if (balance.Quantity < line.Quantity) await mover.MoveAsync(line.FK_ItemId, itemName,
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return."); warehouseId, LocationType.Warehouse,
StockService.ExternalLocationId, LocationType.External,
balance.Quantity -= line.Quantity; line.Quantity, MovementType.PurchaseReturn, purchaseReturn.Id, createdByUserId, request.Reason);
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 uow.CommitAsync(ct); await uow.CommitAsync(ct);
@@ -42,7 +42,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct); var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); 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 }); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
} }
} }
+71 -3
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Category;
using Distribution.Features.Item; using Distribution.Features.Item;
using Distribution.Features.ShopPO; using Distribution.Features.ShopPO;
using Distribution.Features.Stock; using Distribution.Features.Stock;
@@ -10,7 +11,8 @@ public class ReturnService(
IReturnRepository repo, IReturnRepository repo,
IShopPORepository shopPORepo, IShopPORepository shopPORepo,
IItemRepository itemRepo, IItemRepository itemRepo,
IStockRepository stockRepo, ICategoryRepository categoryRepo,
StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static SaleReturnItemResponse MapItem(SaleReturnItem i) => private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
@@ -57,6 +59,72 @@ public class ReturnService(
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) => public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList(); (await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) => /// <summary>
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.
/// </summary>
public async Task<SaleReturnResponse> 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!);
}
} }
+4 -3
View File
@@ -1,11 +1,12 @@
using Distribution.Models; using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride; namespace Distribution.Features.Ride;
[ApiController] [ApiController]
[Route("api/rides")] [Route("api/rides")]
public class RideController(RideService service) : ControllerBase public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll( public async Task<IActionResult> GetAll(
@@ -54,7 +55,7 @@ public class RideController(RideService service) : ControllerBase
{ {
try try
{ {
var result = await service.CloseAsync(id, request, ct); var result = await service.CloseAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -66,7 +67,7 @@ public class RideController(RideService service) : ControllerBase
{ {
try try
{ {
var result = await service.CancelAsync(id, request, ct); var result = await service.CancelAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
+16 -14
View File
@@ -10,7 +10,7 @@ public class RideService(
IRideRepository rideRepo, IRideRepository rideRepo,
IDriverProfileRepository driverRepo, IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo, IDriverComplianceConfigRepository complianceRepo,
IStockRepository stockRepo, StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize) public async Task<PagedResponse<RideResponse>> GetAllAsync(RideStatus? status, Guid? driverProfileId, Guid? vehicleId, int page, int pageSize)
@@ -76,7 +76,7 @@ public class RideService(
return ToResponse(saved!); return ToResponse(saved!);
} }
public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, CancellationToken ct = default) public async Task<RideResponse> CloseAsync(Guid rideId, CloseRideRequest request, Guid userId, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
@@ -87,20 +87,24 @@ public class RideService(
if (request.Meterend < ride.Meterstart) if (request.Meterend < ride.Meterstart)
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({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.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now; ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend; ride.Meterend = request.Meterend;
// Whatever wasn't delivered goes back to the warehouse it was loaded from.
await uow.BeginAsync(ct); 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; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride); return ToResponse(ride);
} }
public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, CancellationToken ct = default) public async Task<RideResponse> CancelAsync(Guid rideId, CancelRideRequest request, Guid userId, CancellationToken ct = default)
{ {
var ride = await rideRepo.GetByIdAsync(rideId) var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found"); ?? throw new KeyNotFoundException("Ride not found");
@@ -113,7 +117,12 @@ public class RideService(
ride.CancelReason = request.Reason; ride.CancelReason = request.Reason;
await uow.BeginAsync(ct); 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; } catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride); return ToResponse(ride);
@@ -139,13 +148,6 @@ public class RideService(
throw new InvalidOperationException("Driver medical clearance has expired"); 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) => private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status, new(r.Id, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty, r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
@@ -7,6 +7,8 @@ public interface IStockRepository
// Balance // Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId); Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId); Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId);
/// <summary>Positive balances for the item at the location in pick order: batches by earliest expiry, serials oldest first, untracked last.</summary>
Task<List<StockBalance>> GetAllocatableBalancesAsync(Guid itemId, Guid locationId);
Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType); Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize); Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId); Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
@@ -19,14 +21,21 @@ public interface IStockRepository
Task AddMovementAsync(StockMovement movement); Task AddMovementAsync(StockMovement movement);
Task<StockMovement?> GetMovementByIdAsync(Guid id); Task<StockMovement?> GetMovementByIdAsync(Guid id);
void UpdateMovement(StockMovement movement); void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize); Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to); Task<int> GetMovementsCountAsync(MovementFilter filter);
/// <summary>Opening balance, quantity in/out within [from, toExclusive), closing balance, and the live balance to reconcile against.</summary>
Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime toExclusive);
Task<List<StockMovement>> GetMovementsByReferenceAsync(Guid referenceId, Guid itemId, MovementType type);
/// <summary>The warehouse the item most recently arrived at <paramref name="vehicleId"/> from, or null if it never did.</summary>
Task<Guid?> GetLastWarehouseSourceAsync(Guid vehicleId, Guid itemId);
// Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null). // Batches — a null batchNumber always creates a fresh expiry-only lot (no dedup possible on null).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber); Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch); Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers // Serial numbers
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber); Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber);
/// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary> /// <summary>One past the highest numeric suffix among the item's existing serials that start with <paramref name="prefix"/> (1 if none).</summary>
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix); Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
+19 -1
View File
@@ -52,10 +52,28 @@ public class StockController(StockService service, ICurrentUserService currentUs
[FromQuery] MovementType? type, [FromQuery] MovementType? type,
[FromQuery] DateTime? from, [FromQuery] DateTime? from,
[FromQuery] DateTime? to, [FromQuery] DateTime? to,
[FromQuery] Guid? referenceId,
[FromQuery] Guid? batchId,
[FromQuery] Guid? serialId,
[FromQuery] Guid? createdBy,
[FromQuery] string? reasonCode,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [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);
}
/// <summary>Opening / in / out / closing quantity for one item at one location over a date range.</summary>
[HttpGet("movements/summary")]
public async Task<IActionResult> 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); return Ok(result);
} }
+26 -1
View File
@@ -45,7 +45,12 @@ public record StockMovementResponse(
DateTime CreatedAt, DateTime CreatedAt,
Guid CreatedBy, Guid CreatedBy,
string? FromLocationName, string? FromLocationName,
string? ToLocationName); string? ToLocationName,
Guid? BatchId = null,
string? BatchNumber = null,
DateTime? BatchExpiryDate = null,
Guid? SerialId = null,
string? SerialNumber = null);
// Write requests // Write requests
public record TransferRequest( public record TransferRequest(
@@ -82,3 +87,23 @@ public record AdjustmentRequest(
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded // 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. // (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
public record UpdateMovementReasonRequest(string ReasonCode); 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);
+234
View File
@@ -0,0 +1,234 @@
using Distribution.Features.SysConfig;
using Distribution.Models;
namespace Distribution.Features.Stock;
/// <summary>
/// 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.
/// </summary>
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<List<StockMovement>> 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<StockMovement>();
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<StockMovement>> 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<StockKey, decimal>();
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<StockMovement>();
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;
}
/// <summary>
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
/// </summary>
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;
}
}
}
+97 -27
View File
@@ -7,16 +7,39 @@ namespace Distribution.Features.Stock;
public class StockRepository(AppDBContext db) : IStockRepository public class StockRepository(AppDBContext db) : IStockRepository
{ {
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) => public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances GetBalanceAsync(itemId, locationId, null, null);
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) => // Looks at rows added earlier in the same unit of work first — they aren't in the database yet,
db.StockBalances // so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate.
public async Task<StockBalance?> 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) .Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId .FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId); && b.FK_ItemBatchId == itemBatchId && b.FK_ItemSerialNumberId == itemSerialNumberId);
}
public async Task<List<StockBalance>> 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<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) => public Task<List<StockBalance>> GetBalancesByLocationAsync(Guid locationId, LocationType locationType) =>
db.StockBalances db.StockBalances
@@ -69,41 +92,88 @@ public class StockRepository(AppDBContext db) : IStockRepository
await db.StockMovements.AddAsync(movement); await db.StockMovements.AddAsync(movement);
public Task<StockMovement?> GetMovementByIdAsync(Guid id) => public Task<StockMovement?> 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) => public void UpdateMovement(StockMovement movement) =>
db.StockMovements.Update(movement); db.StockMovements.Update(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) private IQueryable<StockMovement> FilterMovements(MovementFilter f)
{
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<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
{ {
var query = db.StockMovements.AsQueryable(); var query = db.StockMovements.AsQueryable();
if (locationId.HasValue) if (f.LocationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value); query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value); if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value); if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value); if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value); if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value);
return query.CountAsync(); 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<List<StockMovement>> 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<int> GetMovementsCountAsync(MovementFilter filter) =>
FilterMovements(filter).CountAsync();
public async Task<MovementSummary> 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<List<StockMovement>> 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<Guid?> 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<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) => public Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber) =>
db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber); db.ItemBatches.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.BatchNumber == batchNumber);
public async Task AddItemBatchAsync(ItemBatch batch) => public async Task AddItemBatchAsync(ItemBatch batch) =>
await db.ItemBatches.AddAsync(batch); await db.ItemBatches.AddAsync(batch);
public Task<ItemSerialNumber?> GetSerialAsync(Guid serialId) =>
db.ItemSerialNumbers.FirstOrDefaultAsync(s => s.Id == serialId);
public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) => public Task<bool> SerialNumberExistsAsync(Guid itemId, string serialNumber) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber); db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
+103 -104
View File
@@ -13,6 +13,7 @@ public class StockService(
ICategoryRepository categoryRepo, ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo, IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo, IVehicleRepository vehicleRepo,
StockMover mover,
IUnitOfWork uow) IUnitOfWork uow)
{ {
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) => 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) => private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType, new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode, 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<StockMovementResponse> MapMovementAsync(StockMovement m) private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{ {
@@ -112,11 +115,16 @@ public class StockService(
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
} }
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync( public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{ {
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize); var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to); return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive);
}
public async Task<PagedResponse<StockMovementResponse>> 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)>(); var locations = new List<(Guid, LocationType)>();
foreach (var m in items) foreach (var m in items)
@@ -137,60 +145,42 @@ public class StockService(
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default) public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{ {
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity); var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity); movements = await mover.MoveAsync(request.ItemId, item.Name,
await repo.AddMovementAsync(movement); request.FromLocationId, request.FromLocationType,
request.ToLocationId, request.ToLocationType,
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement); return await MapMovementsAsync(movements, item, request.Quantity);
} }
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default) public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{ {
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity); var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity); movements = await mover.MoveAsync(request.ItemId, item.Name,
await repo.AddMovementAsync(movement); request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
await uow.CommitAsync(ct); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement); return await MapMovementsAsync(movements, item, request.Quantity);
} }
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default) public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
@@ -204,75 +194,89 @@ public class StockService(
if (!category.AllowCustomerReturn) if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'."); throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
var movement = new StockMovement var tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
{ if (tracked && request.ReferenceId is null)
FK_ItemId = request.ItemId, throw new InvalidOperationException(
FromLocationId = ExternalLocationId, $"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity); if (tracked)
await repo.AddMovementAsync(movement); {
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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement); return await MapMovementsAsync(movements, item, request.Quantity);
} }
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default) public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{ {
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId); var item = await itemRepo.GetByIdAsync(request.ItemId)
if (balance is not null && balance.Quantity + request.Quantity < 0) ?? throw new InvalidOperationException("Item not found.");
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
};
List<StockMovement> movements;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
if (balance is null) if (request.Quantity < 0)
{ {
await repo.AddBalanceAsync(new StockBalance movements = await mover.MoveAsync(request.ItemId, item.Name,
{ request.LocationId, request.LocationType,
FK_ItemId = request.ItemId, ExternalLocationId, LocationType.External,
LocationId = request.LocationId, -request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
LocationType = request.LocationType,
Quantity = request.Quantity
});
} }
else else
{ {
balance.Quantity += request.Quantity; // A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
balance.UpdatedAt = DateTime.Now; if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
repo.UpdateBalance(balance); 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); await uow.CommitAsync(ct);
} }
catch { await uow.RollbackAsync(ct); throw; } 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. // 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."); ?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode; 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); return await MapMovementAsync(movement);
} }
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity) /// <summary>
/// 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.
/// </summary>
private async Task<StockMovementResponse> MapMovementsAsync(List<StockMovement> movements, Models.Item item, decimal totalQuantity)
{ {
var balance = await repo.GetBalanceAsync(itemId, locationId); var first = movements[0];
if (balance is null || balance.Quantity < quantity) first.Item ??= item;
throw new InvalidOperationException("Insufficient stock at the specified location."); return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
}
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);
} }
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity) private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
@@ -326,10 +331,4 @@ public class StockService(
repo.UpdateBalance(balance); 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);
}
} }
@@ -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<bool> IsWarehouseAsync(Guid locationId);
Task<OverviewAggregate> GetOverviewAsync(Guid? locationId, bool includeWarehouseLow, DateTime today, int expiryDays);
Task<(List<LedgerAggregate> Rows, int Total)> GetLedgerAsync(StockReportFilter f, bool includeWarehouseLow, int page, int pageSize);
Task<(List<ExpiryAggregate> Rows, int Total)> GetExpiryAsync(Guid? locationId, string? search, DateTime cutoff, int page, int pageSize);
}
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}
}
@@ -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);
@@ -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<StockBalance> 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<bool> IsWarehouseAsync(Guid locationId) =>
db.Warehouses.AsNoTracking().AnyAsync(w => w.Id == locationId);
public async Task<OverviewAggregate> 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<LedgerAggregate> 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<Func<StockMovement, bool>> 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<Dictionary<Guid, decimal>> SumByItem(
IQueryable<StockMovement> q, Expression<Func<StockMovement, bool>> 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<ExpiryAggregate> 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);
}
}
@@ -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<bool> IncludesWarehouses(Guid? locationId) =>
!locationId.HasValue || await repo.IsWarehouseAsync(locationId.Value);
public async Task<StockOverview> 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<PagedResponse<StockLedgerRow>> 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<StockLedgerRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<StockExpiryRow>> 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<StockExpiryRow>(page, pageSize, total, mapped);
}
}
@@ -0,0 +1,21 @@
namespace Distribution.Features.Vehicle;
public interface IVehicleDashboardRepository
{
Task<List<VehicleBalanceRow>> GetStockOnBoardAsync(Guid vehicleId);
Task<List<VehicleMovementRow>> GetMovementsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleRideRow>> GetRidesAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<VehicleRideRow?> GetOpenRideAsync(Guid vehicleId);
Task<List<VehicleTaskRow>> GetTasksAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleCollectionRow>> GetCollectionsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<VehicleReturnTotals> GetReturnTotalsAsync(Guid vehicleId, DateTime from, DateTime toExclusive);
Task<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> driverProfileIds);
Task<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId);
Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId);
Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids);
Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids);
}
@@ -4,7 +4,7 @@ namespace Distribution.Features.Vehicle;
[ApiController] [ApiController]
[Route("api/vehicles")] [Route("api/vehicles")]
public class VehicleController(VehicleService service) : ControllerBase public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll() => public async Task<IActionResult> GetAll() =>
@@ -17,6 +17,15 @@ public class VehicleController(VehicleService service) : ControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpGet("{id:guid}/dashboard")]
public async Task<IActionResult> 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] [HttpPost]
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
{ {
@@ -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<VehicleDashboardStockRow> StockOnBoard,
List<VehicleDashboardRide> Rides,
List<VehicleDashboardDailyDelivery> DailyDeliveries,
List<VehicleDashboardDailyMovement> DailyMovement,
List<VehicleDashboardMovementType> MovementByType,
List<VehicleDashboardLedgerRow> Ledger,
List<VehicleDashboardCancelReason> CancelReasons,
List<VehicleDashboardCollection> CollectionsByMethod,
VehicleDashboardReturns Returns,
List<VehicleDashboardTopStore> TopStores,
List<VehicleDashboardDriver> Drivers);
@@ -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<List<VehicleBalanceRow>> 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<List<VehicleMovementRow>> 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<List<VehicleRideRow>> 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<VehicleRideRow?> 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<List<VehicleTaskRow>> 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<List<VehicleCollectionRow>> 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<VehicleReturnTotals> 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<List<VehicleDriverRow>> GetDriversAsync(IEnumerable<Guid> 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<VehicleNamedRef?> GetWarehouseRefAsync(Guid? warehouseId) =>
warehouseId is null
? Task.FromResult<VehicleNamedRef?>(null)
: db.Warehouses.AsNoTracking()
.Where(w => w.Id == warehouseId)
.Select(w => new VehicleNamedRef(w.Id, w.Name))
.FirstOrDefaultAsync();
public Task<VehicleNamedRef?> GetMobileWarehouseAsync(Guid vehicleId) =>
db.Warehouses.AsNoTracking()
.Where(w => w.VehicleId == vehicleId)
.Select(w => new VehicleNamedRef(w.Id, w.Name))
.FirstOrDefaultAsync();
public async Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids)
{
var idList = ids.Distinct().ToList();
var names = new Dictionary<Guid, string>();
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<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> 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);
}
}
@@ -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;
}
/// <summary>Unlike the standard vehicle lookup, this also serves inactive vehicles so their history stays viewable.</summary>
public async Task<VehicleDashboardResponse?> 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<VehicleDashboardDailyDelivery>();
var dailyMovement = new List<VehicleDashboardDailyMovement>();
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<Guid, string> 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",
};
}
}
@@ -0,0 +1,23 @@
namespace Distribution.Features.Warehouse;
public interface IWarehouseDashboardRepository
{
Task<List<WarehouseBalanceRow>> GetBalanceRowsAsync(Guid warehouseId);
Task<List<WarehouseMovementRow>> GetMovementsAsync(Guid warehouseId, DateTime from, DateTime toExclusive);
Task<List<WarehouseLastOutboundRow>> GetLastOutboundAsync(Guid warehouseId);
Task<WarehouseGrnTotals> GetGrnTotalsAsync(Guid warehouseId, DateTime from, DateTime toExclusive);
Task<List<DashboardGrnRow>> GetRecentGrnsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take);
Task<List<DashboardLoadingSheetRow>> GetRecentLoadingSheetsAsync(Guid warehouseId, DateTime from, DateTime toExclusive, int take);
Task<decimal> GetPendingDispatchUnitsAsync(Guid warehouseId);
Task<List<WarehouseVehicleStockRow>> GetVehicleStockAsync(Guid warehouseId, Guid? primaryVehicleId);
Task<List<DashboardLinkedVehicle>> GetDefaultVehiclesAsync(Guid warehouseId, Guid? primaryVehicleId);
Task<int> GetOpenClaimCountAsync(Guid warehouseId);
Task<List<DashboardClaimRow>> GetOpenClaimsAsync(Guid warehouseId, int take);
Task<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids);
Task<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> ids);
}
@@ -4,7 +4,7 @@ namespace Distribution.Features.Warehouse;
[ApiController] [ApiController]
[Route("api/warehouses")] [Route("api/warehouses")]
public class WarehouseController(WarehouseService service) : ControllerBase public class WarehouseController(WarehouseService service, WarehouseDashboardService dashboardService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll() => public async Task<IActionResult> GetAll() =>
@@ -17,6 +17,16 @@ public class WarehouseController(WarehouseService service) : ControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpGet("{id:guid}/dashboard")]
public async Task<IActionResult> 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] [HttpPost]
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct) public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
{ {
@@ -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<DashboardBatch> 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<DashboardGrnRow> 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<DashboardLoadingSheetRow> 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<DashboardClaimRow> 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<DashboardLinkedVehicle> LinkedVehicles,
DashboardPeriod Period,
int DeadStockDays,
DashboardKpis Kpis,
List<DashboardStockRow> Stock,
List<DashboardCategoryStock> StockByCategory,
List<DashboardExpiringBatch> ExpiringBatches,
List<DashboardDailyPoint> DailyMovement,
List<DashboardMovementTypeRow> MovementByType,
List<DashboardLedgerRow> Ledger,
DashboardInbound Inbound,
DashboardOutbound Outbound,
List<DashboardVehicle> Vehicles,
DashboardLosses Losses,
List<DashboardTopMover> TopMovers,
List<DashboardDeadStock> DeadStock);
@@ -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<List<WarehouseBalanceRow>> 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<List<WarehouseMovementRow>> 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<List<WarehouseLastOutboundRow>> 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<WarehouseGrnTotals> 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<List<DashboardGrnRow>> 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<List<DashboardLoadingSheetRow>> 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<decimal> 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<List<WarehouseVehicleStockRow>> 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<List<DashboardLinkedVehicle>> 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<int> GetOpenClaimCountAsync(Guid warehouseId) =>
db.SupplierClaims.AsNoTracking()
.CountAsync(c => c.Status == SupplierClaimStatus.Open
&& c.GoodsReceipt != null && c.GoodsReceipt.FK_WarehouseId == warehouseId);
public Task<List<DashboardClaimRow>> 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<Dictionary<Guid, string>> GetLocationNamesAsync(IEnumerable<Guid> ids)
{
var idList = ids.Distinct().ToList();
var names = new Dictionary<Guid, string>();
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<Dictionary<Guid, string>> GetUserNamesAsync(IEnumerable<Guid> 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);
}
}
@@ -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;
}
/// <summary>Unlike the standard warehouse lookup, this also serves inactive warehouses so their history stays viewable.</summary>
public async Task<WarehouseDashboardResponse?> 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<DashboardDailyPoint>();
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<WarehouseBalanceRow> 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<Guid, string> 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",
};
}
}
+8
View File
@@ -32,6 +32,7 @@ using Distribution.Features.User;
using Distribution.Features.Vehicle; using Distribution.Features.Vehicle;
using Distribution.Features.ProductReport; using Distribution.Features.ProductReport;
using Distribution.Features.Stock; using Distribution.Features.Stock;
using Distribution.Features.StockReport;
using Distribution.Features.SysConfig; using Distribution.Features.SysConfig;
using Distribution.Features.Warehouse; using Distribution.Features.Warehouse;
using Distribution.Infra.Authorization; using Distribution.Infra.Authorization;
@@ -51,8 +52,10 @@ builder.Services.AddScoped<IUnitOfWork, EFUnitOfWork>();
builder.Services.AddScoped<IRegionRepository, RegionRepository>(); builder.Services.AddScoped<IRegionRepository, RegionRepository>();
builder.Services.AddScoped<IRouteRepository, RouteRepository>(); builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>(); builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IVehicleDashboardRepository, VehicleDashboardRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>(); builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>(); builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IWarehouseDashboardRepository, WarehouseDashboardRepository>();
builder.Services.AddScoped<IShopPORepository, ShopPORepository>(); builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>(); builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserRepository, UserRepository>();
@@ -65,6 +68,8 @@ builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>(); builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>(); builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
builder.Services.AddScoped<ProductReportService>(); builder.Services.AddScoped<ProductReportService>();
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
builder.Services.AddScoped<StockReportService>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>(); builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>(); builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>(); builder.Services.AddScoped<IRideRepository, RideRepository>();
@@ -85,8 +90,10 @@ builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<RegionService>(); builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>(); builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>(); builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<VehicleDashboardService>();
builder.Services.AddScoped<StoreService>(); builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>(); builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<WarehouseDashboardService>();
builder.Services.AddScoped<ShopPOService>(); builder.Services.AddScoped<ShopPOService>();
builder.Services.AddScoped<InvoiceService>(); builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<UserService>(); builder.Services.AddScoped<UserService>();
@@ -98,6 +105,7 @@ builder.Services.AddScoped<UomService>();
builder.Services.AddScoped<ItemService>(); builder.Services.AddScoped<ItemService>();
builder.Services.AddScoped<SyncService>(); builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<StockService>(); builder.Services.AddScoped<StockService>();
builder.Services.AddScoped<StockMover>();
builder.Services.AddScoped<PriceListService>(); builder.Services.AddScoped<PriceListService>();
builder.Services.AddScoped<SupplierService>(); builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>(); builder.Services.AddScoped<DriverProfileService>();
@@ -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 <p className="text-xs text-muted-foreground">{text}</p>
}
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 <Empty text={emptyText} />
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 (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="name" />} />
<Pie data={slices} dataKey="value" nameKey="name" innerRadius="55%" strokeWidth={2}>
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) - 6} className="fill-foreground text-base font-semibold">{fmtCompact(total)}</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 12} className="fill-muted-foreground text-[11px]">{centerLabel}</tspan>
</text>
)
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
)
}
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 <Empty text={emptyText} />
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data} layout="vertical" margin={{ left: 0, right: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={96} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={22}>
{data.map(d => <Cell key={d.name} fill={color} />)}
</Bar>
</BarChart>
</ChartContainer>
)
}
export function ColumnBarChart({ data, emptyText, label }: { data: ChartDatum[]; emptyText: string; label: string }) {
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} interval={0} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={40} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={36} />
</BarChart>
</ChartContainer>
)
}
export function SalesTrendChart({ data }: { data: MonthlySalesPoint[] }) {
if (data.length === 0) return <Empty text="No invoices yet." />
const config = { invoiced: { label: 'Invoiced', color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<AreaChart data={data}>
<defs>
<linearGradient id="dashFillInvoiced" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-invoiced)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-invoiced)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickFormatter={fmtMonth} tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => fmtMonth(String(v))} />} />
<Area dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" fill="url(#dashFillInvoiced)" />
</AreaChart>
</ChartContainer>
)
}
export function RadialGauge({ pct, label, sub }: { pct: number; label: string; sub: string }) {
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<div className="flex h-full min-h-32 items-center gap-4">
<div className="relative aspect-square h-full max-h-40 min-h-24">
<ChartContainer config={config} className="size-full">
<RadialBarChart data={[{ value: pct, fill: 'var(--color-value)' }]} startAngle={90} endAngle={-270} innerRadius="75%" outerRadius="100%">
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" background cornerRadius={10} />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground">{pct}%</div>
</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</div>
)
}
@@ -22,6 +22,8 @@ import {
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' 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 STORAGE_KEY = 'distribution-dashboard-widgets-v3'
const ROW_HEIGHT = 36 const ROW_HEIGHT = 36
@@ -31,7 +33,7 @@ type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
type Category = 'sales' | 'ops' | 'stock' | 'finance' type Category = 'sales' | 'ops' | 'stock' | 'finance'
type Size = 'sm' | 'md' | 'lg' type Size = 'sm' | 'md' | 'lg' | 'chart' | 'chartWide'
interface WidgetMeta { interface WidgetMeta {
id: string id: string
@@ -59,11 +61,18 @@ const WIDGET_META: WidgetMeta[] = [
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' }, { id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' }, { id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' },
{ id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' }, { 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 = [ const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus', 'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus', '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 /** 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<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = { const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
lg: { lg: {
sm: { w: 3, h: 4, minW: 2, minH: 3 }, 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 }, 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: { md: {
sm: { w: 5, h: 4, minW: 3, minH: 3 }, 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 }, 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: {
sm: { w: 3, h: 4, minW: 2, minH: 3 }, 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 }, 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: { xs: {
sm: { w: 4, h: 4, minW: 2, minH: 3 }, 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 }, 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: { xxs: {
sm: { w: 2, h: 4, minW: 1, minH: 3 }, 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 }, 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 draftInvoiceCount: number
printedInvoiceCount: number printedInvoiceCount: number
finalizedInvoiceCount: 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<DashboardData> { async function loadDashboardData(): Promise<DashboardData> {
@@ -211,6 +256,8 @@ async function loadDashboardData(): Promise<DashboardData> {
balances, balances,
outstanding, outstanding,
draftInv, printedInv, finalizedInv, draftInv, printedInv, finalizedInv,
poStatusCounts, paymentStatusCounts,
recentInvoices, recentMovements,
] = await Promise.all([ ] = await Promise.all([
shopPos.list({ status: 'Pending', pageSize: 1 }), shopPos.list({ status: 'Pending', pageSize: 1 }),
shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }), shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }),
@@ -228,8 +275,17 @@ async function loadDashboardData(): Promise<DashboardData> {
invoices.list({ status: 'Draft', pageSize: 1 }), invoices.list({ status: 'Draft', pageSize: 1 }),
invoices.list({ status: 'Printed', pageSize: 1 }), invoices.list({ status: 'Printed', pageSize: 1 }),
invoices.list({ status: 'Finalized', 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<string, number>()
for (const m of getPaginatedItems(recentMovements)) {
movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1)
}
const balanceRows = getPaginatedItems(balances) const balanceRows = getPaginatedItems(balances)
const byWarehouse = new Map<string, WarehouseBalanceRow>() const byWarehouse = new Map<string, WarehouseBalanceRow>()
for (const b of balanceRows) { for (const b of balanceRows) {
@@ -259,6 +315,10 @@ async function loadDashboardData(): Promise<DashboardData> {
draftInvoiceCount: draftInv.total, draftInvoiceCount: draftInv.total,
printedInvoiceCount: printedInv.total, printedInvoiceCount: printedInv.total,
finalizedInvoiceCount: finalizedInv.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 (
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
)
}
function WidgetBody({ id, data }: { id: string; data: DashboardData }) { function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
switch (id) { switch (id) {
case 'pendingPO': case 'pendingPO':
@@ -297,13 +349,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
) )
case 'fulfillRate': { case 'fulfillRate': {
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0 const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
return ( return <RadialGauge pct={pct} label="Fulfilled" sub={`${data.fulfilledPOCount} of ${data.totalPOCount} orders fulfilled`} />
<div>
<div className="mb-2.5 text-2xl font-bold text-foreground">{pct}%</div>
<Bar pct={pct} colorClass="bg-blue-600" />
<div className="mt-2.5 text-xs text-muted-foreground">{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled</div>
</div>
)
} }
case 'openRides': case 'openRides':
return data.openRides.length === 0 ? ( return data.openRides.length === 0 ? (
@@ -321,29 +367,16 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))} ))}
</div> </div>
) )
case 'loadingSheetsByStatus': { 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
return ( return (
<div className="flex flex-col gap-3"> <HorizontalBarChart
<div> data={[
<div className="mb-1 flex justify-between text-xs"> { name: 'Planned', value: data.plannedSheetsCount },
<span className="text-muted-foreground">Planned</span> { name: 'Confirmed', value: data.confirmedSheetsCount },
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span> ]}
</div> emptyText="No loading sheets yet."
<Bar pct={plannedPct} colorClass="bg-amber-600" /> />
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Confirmed</span>
<span className="font-semibold text-foreground">{data.confirmedSheetsCount}</span>
</div>
<Bar pct={confirmedPct} colorClass="bg-violet-600" />
</div>
</div>
) )
}
case 'deliveryTasks': case 'deliveryTasks':
return ( return (
<div> <div>
@@ -387,24 +420,8 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))} ))}
</div> </div>
) )
case 'warehouseBalance': { case 'warehouseBalance':
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity)) return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
return data.warehouseBalances.length === 0 ? (
<p className="text-xs text-muted-foreground">No warehouse stock recorded.</p>
) : (
<div className="flex flex-col gap-3">
{data.warehouseBalances.map(w => (
<div key={w.locationId}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">{w.name}</span>
<span className="font-semibold text-foreground">{w.quantity}</span>
</div>
<Bar pct={(w.quantity / max) * 100} colorClass="bg-amber-600" />
</div>
))}
</div>
)
}
case 'outstandingPay': case 'outstandingPay':
return ( return (
<div> <div>
@@ -412,34 +429,39 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div> <div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
</div> </div>
) )
case 'invoiceStatus': { 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' },
]
return ( return (
<div> <DonutChart
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted"> data={[
{segs.map(s => ( { name: 'Draft', value: data.draftInvoiceCount },
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} /> { name: 'Printed', value: data.printedInvoiceCount },
))} { name: 'Finalized', value: data.finalizedInvoiceCount },
</div> ]}
<div className="mt-3.5 flex flex-col gap-1.5"> centerLabel="invoices"
{segs.map(s => ( emptyText="No invoices yet."
<div key={s.label} className="flex items-center justify-between text-xs"> />
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label}
</span>
<span className="font-semibold text-foreground">{s.count}</span>
</div>
))}
</div>
</div>
) )
} case 'salesTrendChart':
return <SalesTrendChart data={data.monthlySales} />
case 'paymentStatusChart':
return <DonutChart data={data.paymentStatusChart} centerLabel="invoices" emptyText="No invoices yet." />
case 'poStatusChart':
return <DonutChart data={data.poStatusChart} centerLabel="orders" emptyText="No shop POs yet." />
case 'loadingSheetsChart':
return (
<DonutChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
centerLabel="sheets"
emptyText="No loading sheets yet."
/>
)
case 'warehouseChart':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'movementTypeChart':
return <ColumnBarChart data={data.movementTypeChart} label="Movements" emptyText="No recent stock movements." />
default: default:
return null return null
} }
@@ -653,7 +675,9 @@ export function DashboardClient() {
<div className="text-sm font-semibold text-foreground">{w.title}</div> <div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div> <div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div> </div>
<WidgetBody id={w.id} data={data} /> <div className="min-h-0 flex-1">
<WidgetBody id={w.id} data={data} />
</div>
</div> </div>
))} ))}
</ResponsiveGridLayout> </ResponsiveGridLayout>
@@ -0,0 +1,5 @@
import { VehicleDashboard } from '../_components/dashboard/vehicle-dashboard'
export default function VehicleDashboardPage() {
return <VehicleDashboard />
}
@@ -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<CancelRequestType, string> = {
VehicleBreakdown: 'Vehicle breakdown',
DriverUnavailable: 'Driver unavailable',
RouteIssue: 'Route issue',
Other: 'Other',
}
const RIDE_BADGE: Record<RideStatus, 'default' | 'secondary' | 'destructive'> = {
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 (
<div className="flex flex-col gap-4">
<Section title="Deliveries" description="Delivery tasks completed and failed per day">
<ChartContainer config={deliveryConfig} className="h-64 w-full">
<BarChart data={data.dailyDeliveries}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} width={32} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="delivered" stackId="a" fill="var(--color-delivered)" />
<Bar dataKey="failed" stackId="a" fill="var(--color-failed)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<Section
title="Ride history"
description={`Latest ${rides.length} of ${kpis.rides} rides in this period`}
href="/operations/rides"
hrefLabel="All rides"
>
{rides.length === 0 ? (
<EmptyNote>No rides in this period.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Started</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Distance</TableHead>
<TableHead className="text-right">Delivered</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rides.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap">{fmtDateTime(r.startedAt ?? r.createdAt)}</TableCell>
<TableCell>{r.driverName}</TableCell>
<TableCell><Badge variant={RIDE_BADGE[r.status]}>{r.status}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{r.distanceKm > 0 ? `${fmtNum(r.distanceKm)} km` : '—'}</TableCell>
<TableCell className="text-right tabular-nums">{r.delivered} / {r.tasks}</TableCell>
<TableCell className="text-right tabular-nums">{r.failed}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.status === 'Cancelled'
? `${r.cancelType ? CANCEL_LABEL[r.cancelType] : 'Cancelled'}${r.cancelReason ? ` — ${r.cancelReason}` : ''}`
: r.closedAt ? `Closed ${fmtDateTime(r.closedAt)}` : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Top stores served" description="By value delivered from this vehicle">
{topStores.length === 0 ? (
<EmptyNote>No completed deliveries in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{topStores.map(s => (
<li key={s.storeId} className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{s.storeName}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{s.deliveries} {s.deliveries === 1 ? 'delivery' : 'deliveries'} · {fmtMoney(s.value)}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Cancelled rides" description="Why rides on this vehicle were cancelled">
{cancelReasons.length === 0 ? (
<EmptyNote>No cancelled rides in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{cancelReasons.map(c => (
<li key={c.type} className="flex items-center justify-between text-sm">
<span>{CANCEL_LABEL[c.type]}</span>
<Badge variant="outline">{c.count}</Badge>
</li>
))}
</ul>
)}
</Section>
</div>
</div>
)
}
// ── Stock on board ──────────────────────────────────────────────────────────
export function StockSection({ data }: { data: VehicleDashboardResponse }) {
const { stockOnBoard, kpis } = data
return (
<Section
title="Stock on board"
description={`${stockOnBoard.length} items currently on this vehicle`}
href="/stock"
hrefLabel="Stock balances"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Units on board" value={fmtNum(kpis.stockOnBoardUnits)} />
<Metric label="Value at sale price" value={fmtMoney(kpis.stockOnBoardValue)} />
<Metric label="Loaded" value={fmtNum(kpis.unitsLoaded)} sub="units this period" />
<Metric label="Sold" value={fmtNum(kpis.unitsSold)} sub="units this period" />
</div>
{stockOnBoard.length === 0 ? (
<EmptyNote>The vehicle is empty.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">On board</TableHead>
<TableHead className="text-right">Value</TableHead>
<TableHead>Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockOnBoard.map(r => (
<TableRow key={r.itemId}>
<TableCell>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.quantity)} {r.uom}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.value)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{daysAgo(r.updatedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
)
}
// ── 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 (
<div className="flex flex-col gap-4">
<Section title="Stock movement" description="Units loaded onto and leaving this vehicle, per day">
<ChartContainer config={flowConfig} className="h-64 w-full">
<BarChart data={data.dailyMovement}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="in" fill="var(--color-in)" radius={[4, 4, 0, 0]} />
<Bar dataKey="out" fill="var(--color-out)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<Section title="By movement type" description="Loads from warehouses, sales to customers, returns and transfers">
{data.movementByType.length === 0 ? (
<EmptyNote>No movements in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{data.movementByType.map(r => (
<li key={`${r.type}-${r.direction}`} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
{MOVEMENT_LABEL[r.type]}
</span>
<span className="tabular-nums text-muted-foreground">{fmtNum(r.units)} units · {r.count}×</span>
</li>
))}
</ul>
)}
</Section>
<Section
title="Movement ledger"
description={`Latest ${data.ledger.length} of ${total} movements in this period`}
href="/stock/movements"
hrefLabel="Full movement history"
>
<div className="mb-3 flex gap-1">
{(['all', 'In', 'Out'] as const).map(d => (
<Button key={d} size="sm" variant={direction === d ? 'default' : 'outline'} onClick={() => setDirection(d)}>
{d === 'all' ? 'All' : d}
</Button>
))}
</div>
{ledger.length === 0 ? (
<EmptyNote>No movements to show.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead>From → To</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledger.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{fmtDateTime(r.createdAt)}</TableCell>
<TableCell>
<p className="font-medium">{r.itemName}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
<span className="text-sm">{MOVEMENT_LABEL[r.type]}</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)}</TableCell>
<TableCell className="whitespace-nowrap">{r.fromName} → {r.toName}</TableCell>
<TableCell className="whitespace-nowrap">{r.createdByName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
</div>
)
}
// ── Drivers, collections and returns ────────────────────────────────────────
function ExpiryBadge({ date }: { date: string }) {
const state = expiryState(date)
return (
<span className="flex items-center gap-1.5">
{fmtDate(date)}
{state === 'expired' && <Badge variant="destructive">Expired</Badge>}
{state === 'soon' && <Badge variant="outline">Expiring</Badge>}
</span>
)
}
export function PeopleMoneySection({ data }: { data: VehicleDashboardResponse }) {
const { drivers, collectionsByMethod, returns, kpis } = data
return (
<div className="flex flex-col gap-4">
<Section title="Drivers" description="Drivers who have operated this vehicle in the period, with compliance dates" href="/drivers" hrefLabel="All drivers">
{drivers.length === 0 ? (
<EmptyNote>No driver has used this vehicle in this period.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Driver</TableHead>
<TableHead className="text-right">Rides</TableHead>
<TableHead className="text-right">Distance</TableHead>
<TableHead>Last ride</TableHead>
<TableHead>Licence expiry</TableHead>
<TableHead>Medical clearance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drivers.map(d => (
<TableRow key={d.id}>
<TableCell>
<p className="font-medium">{d.name}{!d.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}</p>
<p className="text-xs text-muted-foreground">{d.licenseNumber}</p>
</TableCell>
<TableCell className="text-right tabular-nums">{d.rides}</TableCell>
<TableCell className="text-right tabular-nums">{d.distanceKm > 0 ? `${fmtNum(d.distanceKm)} km` : '—'}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{d.lastRideAt ? daysAgo(d.lastRideAt) : '—'}</TableCell>
<TableCell><ExpiryBadge date={d.licenseExpiry} /></TableCell>
<TableCell><ExpiryBadge date={d.medicalClearanceExpiry} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Collections" description="Payments received against orders delivered by this vehicle" href="/sales/invoices" hrefLabel="Invoices">
<div className="mb-3 grid grid-cols-2 gap-3">
<Metric label="Collected" value={fmtMoney(kpis.collections)} />
<Metric label="Delivered value" value={fmtMoney(kpis.deliveredValue)} />
</div>
{collectionsByMethod.length === 0 ? (
<EmptyNote>No payments collected in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{collectionsByMethod.map(c => (
<li key={c.method} className="flex items-center justify-between text-sm">
<span>{c.method}</span>
<span className="tabular-nums text-muted-foreground">{fmtMoney(c.amount)} · {c.count} {c.count === 1 ? 'payment' : 'payments'}</span>
</li>
))}
</ul>
)}
</Section>
<Section title="Returns" description="Sale returns on orders delivered by this vehicle" href="/returns-claims/order-returns" hrefLabel="Order returns">
<div className="grid grid-cols-2 gap-3">
<Metric label="Returns" value={String(returns.returnCount)} />
<Metric label="Units returned" value={fmtNum(returns.returnedUnits)} />
<Metric label="Credit issued" value={fmtMoney(returns.creditValue)} />
<Metric label="Received on vehicle" value={fmtNum(returns.customerReturnUnitsOnVehicle)} sub="units from customers" />
</div>
</Section>
</div>
</div>
)
}
@@ -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<VehicleDashboardResponse | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/network/vehicles')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{!vehicle ? (
<Skeleton className="h-7 w-48 rounded-lg" />
) : (
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">{vehicle.numberPlate}</h1>
<Badge variant="outline">{vehicle.type}</Badge>
<Badge variant={vehicle.isActive ? 'default' : 'secondary'}>
{vehicle.isActive ? 'Active' : 'Inactive'}
</Badge>
{data?.currentRide && <Badge>On a ride</Badge>}
</div>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
{vehicle ? `Reg. ${vehicle.regNumber} · Added ${fmtDate(vehicle.createdAt)}` : 'Vehicle overview'}
{data?.defaultWarehouse && (
<> · Home warehouse: <Link href={`/network/warehouses/${data.defaultWarehouse.id}`} className="text-primary hover:underline">{data.defaultWarehouse.name}</Link></>
)}
{data?.mobileWarehouse && (
<> · Mobile warehouse: <Link href={`/network/warehouses/${data.mobileWarehouse.id}`} className="text-primary hover:underline">{data.mobileWarehouse.name}</Link></>
)}
</p>
</div>
</div>
{vehicle && (
<Button onClick={() => setEditOpen(true)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit vehicle
</Button>
)}
</div>
{/* Period */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Activity period</span>
{PRESETS.map(p => (
<Button key={p.days} variant="outline" size="sm" onClick={() => applyPreset(p.days)}>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => e.target.value && setFrom(e.target.value)} className="w-40" />
<span className="text-sm text-muted-foreground">to</span>
<Input type="date" value={to} min={from} max={daysFromToday(0)} onChange={e => e.target.value && setTo(e.target.value)} className="w-40" />
</div>
{!data && loading && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)}
{data && k && (
<div className={`flex flex-col gap-6 transition-opacity ${loading ? 'opacity-60' : ''}`}>
{/* KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric label="Rides" value={String(k.rides)} sub={`${k.closedRides} closed · ${k.cancelledRides} cancelled`} />
<Metric label="Distance" value={`${fmtNum(k.distanceKm)} km`} sub="closed rides" />
<Metric
label="Deliveries"
value={`${k.deliveredCount} / ${k.deliveries}`}
sub={`${k.successRatePercent}% success · ${k.failedCount} failed`}
tone={k.failedCount > 0 ? 'warning' : undefined}
/>
<Metric label="Delivered value" value={fmtMoney(k.deliveredValue)} sub={`${fmtMoney(k.collections)} collected`} />
<Metric label="Stock on board" value={fmtNum(k.stockOnBoardUnits)} sub={fmtMoney(k.stockOnBoardValue)} />
<Metric label="Loaded / sold" value={`${fmtNum(k.unitsLoaded)} / ${fmtNum(k.unitsSold)}`} sub="units this period" />
</div>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing needs attention right now.</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{alerts.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Badge variant={ALERT_BADGE[a.severity].variant} className="mt-0.5 shrink-0">
{ALERT_BADGE[a.severity].label}
</Badge>
<span>{a.text}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Tabs defaultValue="rides">
<TabsList>
<TabsTrigger value="rides">Rides & deliveries</TabsTrigger>
<TabsTrigger value="stock">Stock on board</TabsTrigger>
<TabsTrigger value="movement">Stock movement</TabsTrigger>
<TabsTrigger value="people">Drivers & collections</TabsTrigger>
</TabsList>
<TabsContent value="rides"><RidesSection data={data} /></TabsContent>
<TabsContent value="stock"><StockSection data={data} /></TabsContent>
<TabsContent value="movement"><MovementSection data={data} /></TabsContent>
<TabsContent value="people"><PeopleMoneySection data={data} /></TabsContent>
</Tabs>
</div>
)}
<VehicleSheet
open={editOpen}
onClose={() => setEditOpen(false)}
vehicle={vehicle ?? null}
onSuccess={reload}
/>
</div>
)
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -15,6 +16,7 @@ import type { VehicleResponse } from '@/types/api'
import { VehicleSheet } from './vehicle-sheet' import { VehicleSheet } from './vehicle-sheet'
export function VehiclesClient() { export function VehiclesClient() {
const router = useRouter()
const [data, setData] = useState<VehicleResponse[]>([]) const [data, setData] = useState<VehicleResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -118,6 +120,11 @@ export function VehiclesClient() {
], ],
rowActions: { rowActions: {
actions: (row) => [ actions: (row) => [
{
action: 'view',
label: 'View dashboard',
onClick: (vehicle: VehicleResponse) => router.push(`/network/vehicles/${vehicle.id}`),
},
{ {
action: 'edit', action: 'edit',
label: 'Edit vehicle', label: 'Edit vehicle',
@@ -131,7 +138,7 @@ export function VehiclesClient() {
}, },
], ],
}, },
}), [pageSize, openEdit, deactivate]) }), [pageSize, openEdit, deactivate, router])
return ( return (
<> <>
@@ -0,0 +1,5 @@
import { WarehouseDashboard } from '../_components/dashboard/warehouse-dashboard'
export default function WarehouseDashboardPage() {
return <WarehouseDashboard />
}
@@ -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 (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Section
title="Inbound · Procurement"
description="Goods receipts (GRN) received into this warehouse"
href="/procurement/grn"
hrefLabel="All GRNs"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="GRNs" value={String(inbound.grnCount)} />
<Metric label="Accepted" value={fmtNum(inbound.acceptedUnits)} sub="units" />
<Metric label="Rejected" value={fmtNum(inbound.rejectedUnits)} sub="units" tone={inbound.rejectedUnits > 0 ? 'warning' : undefined} />
<Metric
label="Rejection rate"
value={`${inbound.rejectionRatePercent}%`}
tone={inbound.rejectionRatePercent > 5 ? 'danger' : undefined}
/>
</div>
{inbound.recentGrns.length === 0 ? (
<EmptyNote>No goods received in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Received</TableHead>
<TableHead>Supplier</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inbound.recentGrns.map(g => (
<TableRow key={g.id}>
<TableCell className="whitespace-nowrap">{fmtDate(g.receivedAt)}</TableCell>
<TableCell>{g.supplierName ?? <span className="text-muted-foreground">Direct receipt</span>}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.acceptedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.rejectedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(g.costValue)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Outbound · Loading sheets"
description="Stock dispatched from this warehouse onto delivery vehicles"
href="/operations/loading"
hrefLabel="All loading sheets"
>
<div className="mb-3 grid grid-cols-2 gap-3">
<Metric label="Dispatched" value={fmtNum(outbound.unitsDispatched)} sub="units this period" />
<Metric label="Pending dispatch" value={fmtNum(outbound.pendingDispatchUnits)} sub="on planned sheets" />
</div>
{outbound.recentLoadingSheets.length === 0 ? (
<EmptyNote>No loading sheets in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Prepared</TableHead>
<TableHead>Vehicle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Units</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outbound.recentLoadingSheets.map(s => (
<TableRow key={s.id}>
<TableCell className="whitespace-nowrap">{fmtDate(s.populatedAt)}</TableCell>
<TableCell>{s.vehiclePlate}</TableCell>
<TableCell>
<Badge variant={s.status === 'Confirmed' ? 'default' : 'secondary'}>{s.status}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{s.lines}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(s.units)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Vehicles" description="Stock currently on vehicles linked to or loaded from this warehouse">
{vehicles.length === 0 ? (
<EmptyNote>No vehicles are linked to this warehouse.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Vehicle</TableHead>
<TableHead className="text-right">Units on board</TableHead>
<TableHead className="text-right">SKUs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vehicles.map(v => (
<TableRow key={v.vehicleId}>
<TableCell>
<span className="font-medium">{v.numberPlate}</span>
<span className="ml-2 text-xs text-muted-foreground">{v.regNumber}</span>
{v.isLinked && <Badge variant="outline" className="ml-2">Linked</Badge>}
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(v.units)}</TableCell>
<TableCell className="text-right tabular-nums">{v.skus}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Returns & losses"
description="Stock leaving without a sale, plus open supplier claims"
href="/returns-claims/supplier-claims"
hrefLabel="Supplier claims"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Purchase returns" value={fmtNum(losses.purchaseReturnUnits)} sub="units" />
<Metric label="Damaged" value={fmtNum(losses.damageUnits)} sub="units" tone={losses.damageUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted down" value={fmtNum(losses.negativeAdjustmentUnits)} sub="units" tone={losses.negativeAdjustmentUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted up" value={fmtNum(losses.positiveAdjustmentUnits)} sub="units" />
</div>
{losses.openClaims.length === 0 ? (
<EmptyNote>No open supplier claims for goods received here.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{losses.openClaims.map(c => (
<li key={c.id} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{c.supplierName ?? 'Supplier'}</p>
<p className="truncate text-xs text-muted-foreground">{c.details}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{fmtNum(c.units)} units · {fmtDate(c.createdAt)}</span>
</li>
))}
</ul>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Top movers" description="Most units loaded onto vehicles in this period">
{topMovers.length === 0 ? (
<EmptyNote>Nothing was dispatched in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{topMovers.map(m => (
<li key={m.itemId} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">{m.name} <span className="text-xs font-normal text-muted-foreground">{m.sku}</span></span>
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtNum(m.unitsOut)} units</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${(m.unitsOut / maxMover) * 100}%`, background: 'var(--chart-2)' }} />
</div>
</li>
))}
</ul>
)}
</Section>
<Section title="Dead stock" description={`In stock but not sent out for ${data.deadStockDays}+ days`}>
{deadStock.length === 0 ? (
<EmptyNote>No idle stock found.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead>Last sent out</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deadStock.map(d => (
<TableRow key={d.itemId}>
<TableCell>
<p className="font-medium">{d.name}</p>
<p className="text-xs text-muted-foreground">{d.sku}</p>
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(d.quantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(d.costValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{d.lastOutboundAt ? daysAgo(d.lastOutboundAt) : 'Never'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
</div>
)
}
@@ -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<MovementType, string> = {
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<WarehouseType, string> = {
DistributionCenter: 'Distribution Center',
FulfillmentCenter: 'Fulfillment Center',
CrossDocking: 'Cross Docking',
ColdStorage: 'Cold Storage',
BondedWarehouse: 'Bonded Warehouse',
AutomatedWarehouse: 'Automated Warehouse',
}
@@ -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 (
<Section title={title}>
{rows.length === 0 ? (
<EmptyNote>No movements in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{rows.map(r => (
<li key={r.type} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span>{MOVEMENT_LABEL[r.type]}</span>
<span className="tabular-nums text-muted-foreground">
{fmtNum(r.units)} units · {r.count} {r.count === 1 ? 'entry' : 'entries'}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${total > 0 ? (r.units / total) * 100 : 0}%`,
background: r.direction === 'In' ? 'var(--chart-2)' : 'var(--chart-1)',
}}
/>
</div>
</li>
))}
</ul>
)}
</Section>
)
}
export function MovementSection({ data }: { data: WarehouseDashboardResponse }) {
const [direction, setDirection] = useState<DirectionFilter>('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 (
<div className="flex flex-col gap-4">
<Section title="Stock movement" description="Units received into and sent out of this warehouse, per day">
<ChartContainer config={flowConfig} className="h-64 w-full">
<BarChart data={data.dailyMovement}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="in" fill="var(--color-in)" radius={[4, 4, 0, 0]} />
<Bar dataKey="out" fill="var(--color-out)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<TypeBreakdown title="Where stock comes from (In)" rows={inbound} />
<TypeBreakdown title="Where stock goes (Out)" rows={outbound} />
</div>
<Section
title="Movement ledger"
description={`Latest ${data.ledger.length} of ${totalEntries} movements in this period`}
href="/stock/movements"
hrefLabel="Full movement history"
>
<div className="mb-3 flex gap-1">
{(['all', 'In', 'Out'] as const).map(d => (
<Button key={d} size="sm" variant={direction === d ? 'default' : 'outline'} onClick={() => setDirection(d)}>
{d === 'all' ? 'All' : d}
</Button>
))}
</div>
{ledger.length === 0 ? (
<EmptyNote>No movements to show.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead>From → To</TableHead>
<TableHead>Reference</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledger.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{fmtDateTime(r.createdAt)}</TableCell>
<TableCell>
<p className="font-medium">{r.itemName}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
<span className="text-sm">{MOVEMENT_LABEL[r.type]}</span>
</div>
{r.reasonCode && <p className="mt-0.5 text-xs text-muted-foreground">{r.reasonCode}</p>}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)}
</TableCell>
<TableCell className="whitespace-nowrap">{r.fromName} → {r.toName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.referenceKind ? `${r.referenceKind} · ${r.referenceId?.slice(0, 8)}` : '—'}
</TableCell>
<TableCell className="whitespace-nowrap">{r.createdByName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
</div>
)
}
@@ -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 (
<div className="flex flex-col gap-0.5 rounded-xl border px-3 py-2">
<span className="truncate text-[11px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={`truncate text-base font-semibold leading-tight ${valueClass}`}>{value}</span>
{sub && <span className="truncate text-[11px] text-muted-foreground">{sub}</span>}
</div>
)
}
export function Section({
title,
description,
href,
hrefLabel,
children,
}: {
title: string
description?: string
href?: string
hrefLabel?: string
children: React.ReactNode
}) {
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{href && (
<Link href={href} className="shrink-0 text-xs font-medium text-primary hover:underline">
{hrefLabel ?? 'View all'}
</Link>
)}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
export function EmptyNote({ children }: { children: React.ReactNode }) {
return <p className="py-4 text-center text-sm text-muted-foreground">{children}</p>
}
@@ -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<WarehouseStockStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
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<string | null>(null)
const [adjusting, setAdjusting] = useState<StockBalanceResponse | null>(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 (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Section title="Stock value by category" description="At cost price, items currently on hand">
{data.stockByCategory.length === 0 ? (
<EmptyNote>No stock on hand.</EmptyNote>
) : (
<ChartContainer config={categoryConfig} className="w-full" style={{ height: Math.max(140, data.stockByCategory.length * 36) }}>
<BarChart data={data.stockByCategory} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="category" tickLine={false} axisLine={false} width={110} />
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={(v) => fmtMoney(Number(v))} />} />
<Bar dataKey="value" fill="var(--color-value)" radius={6} />
</BarChart>
</ChartContainer>
)}
</Section>
</div>
<Section title="Expiry watch" description="Batches expired or expiring within 30 days">
{data.expiringBatches.length === 0 ? (
<EmptyNote>No batches at risk.</EmptyNote>
) : (
<ul className="flex max-h-56 flex-col gap-2 overflow-y-auto">
{data.expiringBatches.map(b => (
<li key={`${b.itemId}-${b.batchNumber}-${b.expiryDate}`} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{b.itemName}</p>
<p className="text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'} · {fmtNum(b.quantity)} units · {fmtDate(b.expiryDate)}
</p>
</div>
<Badge variant={b.status === 'Expired' ? 'destructive' : 'outline'}>
{b.status === 'Expired' ? 'Expired' : 'Expiring'}
</Badge>
</li>
))}
</ul>
)}
</Section>
</div>
<Section title="Stock on hand" description={`${data.stock.length} items held in this warehouse`}>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input
value={search}
onChange={e => { setSearch(e.target.value); setVisible(PAGE_SIZE) }}
placeholder="Search item or SKU"
className="w-56"
/>
<Select value={category} onValueChange={v => { setCategory(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="OK">In stock</SelectItem>
<SelectItem value="Low">Low stock</SelectItem>
<SelectItem value="Out">Out of stock</SelectItem>
</SelectContent>
</Select>
</div>
{rows.length === 0 ? (
<EmptyNote>No items match the current filters.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">On hand</TableHead>
<TableHead className="text-right">Reorder at</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead className="text-right">Sale value</TableHead>
<TableHead>Updated</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, visible).map(r => {
const badge = STATUS_BADGE[r.status]
const hasBatches = r.batches.length > 0
return (
<Fragment key={r.itemId}>
<TableRow
className={hasBatches ? 'cursor-pointer' : undefined}
onClick={hasBatches ? () => setExpanded(expanded === r.itemId ? null : r.itemId) : undefined}
>
<TableCell>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">
{r.sku}{r.brand ? ` · ${r.brand}` : ''}{hasBatches ? ` · ${r.batches.length} batch${r.batches.length > 1 ? 'es' : ''}` : ''}
</p>
</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.quantity)} {r.uom}</TableCell>
<TableCell className="text-right tabular-nums">{r.reorderLevel != null ? fmtNum(r.reorderLevel) : '—'}</TableCell>
<TableCell><Badge variant={badge.variant}>{badge.label}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.costValue)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.saleValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{daysAgo(r.updatedAt)}</TableCell>
<TableCell className="text-right">
{!hasBatches && (
<Button variant="ghost" size="xs" onClick={e => { e.stopPropagation(); openAdjust(r) }}>
Adjust
</Button>
)}
</TableCell>
</TableRow>
{expanded === r.itemId && r.batches.map(b => (
<TableRow key={b.batchId} className="bg-muted/40">
<TableCell colSpan={2} className="pl-8 text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtNum(b.quantity)} {r.uom}</TableCell>
<TableCell colSpan={6} className="text-xs text-muted-foreground">
{b.expiryDate ? `Expires ${fmtDate(b.expiryDate)}` : 'No expiry date'}
</TableCell>
</TableRow>
))}
</Fragment>
)
})}
</TableBody>
</Table>
</div>
)}
{rows.length > visible && (
<div className="mt-3 flex justify-center">
<Button variant="outline" size="sm" onClick={() => setVisible(v => v + PAGE_SIZE)}>
Show more ({rows.length - visible} remaining)
</Button>
</div>
)}
</Section>
<StockAdjustDialog
open={adjusting !== null}
onClose={() => setAdjusting(null)}
onSuccess={() => { setAdjusting(null); onChanged() }}
balance={adjusting}
/>
</div>
)
}
@@ -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<WarehouseDashboardResponse | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/network/warehouses')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{!warehouse ? (
<Skeleton className="h-7 w-48 rounded-lg" />
) : (
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">{warehouse.name}</h1>
<Badge variant="outline">{WAREHOUSE_TYPE_LABEL[warehouse.type] ?? warehouse.type}</Badge>
<Badge variant={warehouse.isActive ? 'default' : 'secondary'}>
{warehouse.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
{warehouse
? `${warehouse.location} · Created ${fmtDate(warehouse.createdAt)}`
: 'Warehouse overview'}
{data && data.linkedVehicles.length > 0 && (
<> · Vehicles: {data.linkedVehicles.map(v => v.numberPlate).join(', ')}</>
)}
</p>
</div>
</div>
{warehouse && (
<Button onClick={() => setEditOpen(true)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit warehouse
</Button>
)}
</div>
{/* Period */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Activity period</span>
{PRESETS.map(p => (
<Button key={p.days} variant="outline" size="sm" onClick={() => applyPreset(p.days)}>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => e.target.value && setFrom(e.target.value)} className="w-40" />
<span className="text-sm text-muted-foreground">to</span>
<Input type="date" value={to} min={from} max={daysFromToday(0)} onChange={e => e.target.value && setTo(e.target.value)} className="w-40" />
</div>
{!data && loading && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)}
{data && k && (
<div className={`flex flex-col gap-6 transition-opacity ${loading ? 'opacity-60' : ''}`}>
{/* KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric label="Stock value" value={fmtMoney(k.stockValueCost)} sub={`${fmtMoney(k.stockValueSale)} at sale price`} />
<Metric label="Units on hand" value={fmtNum(k.totalUnits)} sub={`${k.skuCount} SKUs`} />
<Metric
label="Low / out of stock"
value={`${k.lowStockCount} / ${k.outOfStockCount}`}
tone={k.outOfStockCount > 0 ? 'danger' : k.lowStockCount > 0 ? 'warning' : undefined}
sub="items"
/>
<Metric
label="Expiry risk"
value={`${k.nearExpiryCount} / ${k.expiredCount}`}
tone={k.expiredCount > 0 ? 'danger' : k.nearExpiryCount > 0 ? 'warning' : undefined}
sub="near / expired batches"
/>
<Metric label="Units in" value={fmtNum(k.unitsIn)} sub="this period" />
<Metric label="Units out" value={fmtNum(k.unitsOut)} sub="this period" />
</div>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing needs attention right now.</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{alerts.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Badge variant={ALERT_BADGE[a.severity].variant} className="mt-0.5 shrink-0">
{ALERT_BADGE[a.severity].label}
</Badge>
<span>{a.text}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Tabs defaultValue="stock">
<TabsList>
<TabsTrigger value="stock">Stock on hand</TabsTrigger>
<TabsTrigger value="movement">Stock movement</TabsTrigger>
<TabsTrigger value="activity">Module activity</TabsTrigger>
</TabsList>
<TabsContent value="stock"><StockSection data={data} onChanged={reload} /></TabsContent>
<TabsContent value="movement"><MovementSection data={data} /></TabsContent>
<TabsContent value="activity"><ActivitySection data={data} /></TabsContent>
</Tabs>
</div>
)}
<WarehouseSheet
open={editOpen}
onClose={() => setEditOpen(false)}
warehouse={warehouse ?? null}
onSuccess={reload}
/>
</div>
)
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -15,6 +16,7 @@ import type { WarehouseResponse } from '@/types/api'
import { WarehouseSheet } from './warehouse-sheet' import { WarehouseSheet } from './warehouse-sheet'
export function WarehousesClient() { export function WarehousesClient() {
const router = useRouter()
const [data, setData] = useState<WarehouseResponse[]>([]) const [data, setData] = useState<WarehouseResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -118,6 +120,11 @@ export function WarehousesClient() {
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
{
action: 'view',
label: 'View dashboard',
onClick: (warehouse: WarehouseResponse) => router.push(`/network/warehouses/${warehouse.id}`),
},
{ {
action: 'edit', action: 'edit',
label: 'Edit warehouse', label: 'Edit warehouse',
@@ -137,7 +144,7 @@ export function WarehousesClient() {
}, },
], ],
}, },
}), [pageSize, openEdit, toggleActive]) }), [pageSize, openEdit, toggleActive, router])
return ( return (
<> <>
@@ -3,12 +3,14 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table' import { DataTable } from '@/components/shared/data-table'
import { ExportButtons } from '@/components/shared/export-buttons'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { getApiError, getPaginatedItems } from '@/lib/api/index' import { getApiError, getPaginatedItems } from '@/lib/api/index'
import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export'
import { import {
productReports, productReports,
type ProductReportSummary, type ProductReportSummary,
@@ -71,6 +73,44 @@ const returnsColumns: TableConfig<ProductReturnRow>['columns'] = [
}, },
] ]
const STOCK_EXPORT: ExportColumn<ProductStockRow>[] = [
{ 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<ProductSalesRow>[] = [
{ 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<ProductReturnRow>[] = [
{ 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() { export function ProductReportClient() {
const [tab, setTab] = useState<Tab>('stock') const [tab, setTab] = useState<Tab>('stock')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -128,6 +168,30 @@ export function ProductReportClient() {
setTotal(0) 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 common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
const stockConfig = useMemo<TableConfig<ProductStockRow>>( const stockConfig = useMemo<TableConfig<ProductStockRow>>(
@@ -197,6 +261,9 @@ export function ProductReportClient() {
Low stock only Low stock only
</Button> </Button>
)} )}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div> </div>
{tab === 'stock' && ( {tab === 'stock' && (
@@ -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<string, string> = {
OK: 'text-emerald-600',
Low: 'text-amber-600',
Out: 'text-red-600',
}
const ledgerColumns: TableConfig<StockLedgerRow>['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 => <span className={`text-sm font-medium ${STATUS_CLASS[row.status] ?? ''}`}>{row.status}</span>,
},
]
const expiryColumns: TableConfig<StockExpiryRow>['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 => (
<span className={`text-sm font-medium ${row.daysLeft < 0 ? 'text-red-600' : row.daysLeft <= 7 ? 'text-amber-600' : ''}`}>
{row.daysLeft < 0 ? `Expired ${-row.daysLeft}d ago` : `${row.daysLeft}d`}
</span>
),
},
]
const LEDGER_EXPORT: ExportColumn<StockLedgerRow>[] = [
{ 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<StockExpiryRow>[] = [
{ 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<Tab>('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<StockOverview | null>(null)
const [ledgerData, setLedgerData] = useState<StockLedgerRow[]>([])
const [expiryData, setExpiryData] = useState<StockExpiryRow[]>([])
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<TableConfig<StockLedgerRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: ledgerColumns }),
[pageSize],
)
const expiryConfig = useMemo<TableConfig<StockExpiryRow>>(
() => ({
...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 (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">Stock Report</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Opening, in, out and closing stock across all locations or one warehouse or vehicle, plus batches nearing expiry.
</p>
</div>
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
{cards.map(([label, value]) => (
<Card key={label} className="rounded-3xl border-border/60">
<CardContent className="space-y-1">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardContent>
</Card>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{TABS.map(t => (
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
{t.label}
</Button>
))}
<Select value={locationId} onValueChange={v => { setLocationId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-56"><SelectValue placeholder="Location" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All locations</SelectItem>
{locationOptions.map(o => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="w-56"
placeholder="Search name or SKU"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
/>
{tab === 'ledger' ? (
<>
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categoryOptions.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setPage(1) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="low">Low stock</SelectItem>
<SelectItem value="out">Out of stock</SelectItem>
</SelectContent>
</Select>
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
</>
) : (
<Select value={String(expiryDays)} onValueChange={v => { setExpiryDays(Number(v)); setPage(1) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Window" /></SelectTrigger>
<SelectContent>
{EXPIRY_WINDOWS.map(d => <SelectItem key={d} value={String(d)}>Within {d} days</SelectItem>)}
</SelectContent>
</Select>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{tab === 'ledger' && (
<DataTable config={ledgerConfig} data={ledgerData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
{tab === 'expiry' && (
<DataTable config={expiryConfig} data={expiryData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
</>
)
}
@@ -0,0 +1,5 @@
import { StockReportClient } from './_components/stock-report-client'
export default function StockReportPage() {
return <StockReportClient />
}
@@ -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<void>
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 (
<>
<Button size="sm" variant="outline" disabled={disabled || busy !== null} onClick={() => run('excel')}>
{busy === 'excel' ? 'Exporting…' : 'Export Excel'}
</Button>
<Button size="sm" variant="outline" disabled={disabled || busy !== null} onClick={() => run('pdf')}>
{busy === 'pdf' ? 'Exporting…' : 'Export PDF'}
</Button>
</>
)
}
+1
View File
@@ -168,6 +168,7 @@ export const NAV_GROUPS: NavGroup[] = [
icon: ClipboardListIcon, icon: ClipboardListIcon,
items: [ items: [
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' }, { 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' },
], ],
}, },
] ]
+9
View File
@@ -12,6 +12,8 @@ import type {
RouteVisitResponse, RouteVisitResponse,
VehicleResponse, VehicleResponse,
WarehouseResponse, WarehouseResponse,
WarehouseDashboardResponse,
VehicleDashboardResponse,
VehicleType, VehicleType,
WarehouseType, WarehouseType,
} from '@/types/api' } from '@/types/api'
@@ -199,6 +201,10 @@ export const vehicles = {
get: (id: string) => get: (id: string) =>
api.get<VehicleResponse>(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)), api.get<VehicleResponse>(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)),
dashboard: (id: string, params?: { from?: string; to?: string }) =>
api.get<VehicleDashboardResponse>(`/vehicles/${id}/dashboard`, { params })
.then(r => ({ ...r.data, vehicle: normalizeVehicle(r.data.vehicle) })),
create: (body: VehicleBody) => create: (body: VehicleBody) =>
api.post<VehicleResponse>('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)), api.post<VehicleResponse>('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)),
@@ -226,6 +232,9 @@ export const warehouses = {
get: (id: string) => get: (id: string) =>
api.get<WarehouseResponse>(`/warehouses/${id}`).then(r => r.data), api.get<WarehouseResponse>(`/warehouses/${id}`).then(r => r.data),
dashboard: (id: string, params?: { from?: string; to?: string; deadStockDays?: number }) =>
api.get<WarehouseDashboardResponse>(`/warehouses/${id}/dashboard`, { params }).then(r => r.data),
create: (body: WarehouseBody) => create: (body: WarehouseBody) =>
api.post<WarehouseResponse>('/warehouses', body).then(r => r.data), api.post<WarehouseResponse>('/warehouses', body).then(r => r.data),
+74
View File
@@ -72,6 +72,80 @@ export interface ProductStockParams extends ProductReportParams {
lowStockOnly?: boolean 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<StockOverview>('/reports/stock/overview', { params }).then(r => r.data),
ledger: (params?: StockLedgerParams) =>
api.get<PaginatedResponse<StockLedgerRow>>('/reports/stock/ledger', { params }).then(r => r.data),
expiry: (params?: StockExpiryParams) =>
api.get<PaginatedResponse<StockExpiryRow>>('/reports/stock/expiry', { params }).then(r => r.data),
}
export const productReports = { export const productReports = {
stock: (params?: ProductStockParams) => stock: (params?: ProductStockParams) =>
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data), api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
+81
View File
@@ -0,0 +1,81 @@
export interface ExportColumn<T> {
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<T>(
fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>,
): Promise<T[]> {
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<T>(name: string, columns: ExportColumn<T>[], 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<T>(name: string, title: string, columns: ExportColumn<T>[], 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`)
}
+259
View File
@@ -185,6 +185,97 @@ export interface VehicleResponse {
createdAt: string 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 { export interface WarehouseResponse {
id: string id: string
name: string name: string
@@ -195,6 +286,174 @@ export interface WarehouseResponse {
createdAt: string 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 ─────────────────────────────────────────────────────────────────── // ─── Stores ───────────────────────────────────────────────────────────────────
export interface StoreResponse { export interface StoreResponse {