Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b608ad2ef | |||
| 3c75f9786e | |||
| 2d6723a040 | |||
| 2fe2ba007c | |||
| f3f2b71bd4 | |||
| bc72426495 | |||
| da29492798 | |||
| ce21aa6588 | |||
| f44cbf711b | |||
| aaac42b481 | |||
| 7a21cde217 | |||
| 531b3a7fef | |||
| a81e32bbe2 | |||
| 4ef63420b7 | |||
| 9fdf5aa8e3 | |||
| d9c339949e | |||
| 5d18aba1f9 | |||
| 5828879ddc | |||
| d75a43d45f | |||
| 728f2a8b17 | |||
| 1b66d5d767 | |||
| c5a0992b5d | |||
| 09d86835a2 | |||
| d1d2d55352 | |||
| c626f56cdb | |||
| 263e2ea9c0 | |||
| 5c37f02f19 | |||
| 6b3f28d3ae | |||
| e5f6aa15b9 | |||
| 4e12b03e4b | |||
| f5967497be | |||
| 4110e2fcfc | |||
| c52eaabec4 | |||
| 37f4a88014 | |||
| 2d365cd906 | |||
| c68c6e1ab9 | |||
| ddc3ecd3e6 | |||
| d7b6f29200 | |||
| d2277f626c | |||
| 3149902574 | |||
| 37fb72376a | |||
| 52a9a3fde7 | |||
| 1b310c86b3 | |||
| e398ee9d6b | |||
| a3c0211654 | |||
| e4e12aa05a | |||
| 3103fb66ef | |||
| 93355d34f1 | |||
| 9151f47d15 | |||
| 661b522b7d | |||
| e0bb17d0ff | |||
| 0f29413375 | |||
| ef17f7da0a | |||
| d9031f8e73 | |||
| 75578ea7f6 | |||
| 4f02fa303d |
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/dashboard")]
|
||||||
|
public class DashboardReportController(DashboardReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("sales")]
|
||||||
|
public async Task<IActionResult> Sales(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||||
|
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
|
||||||
|
{
|
||||||
|
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
|
||||||
|
var error = DashboardReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("inventory")]
|
||||||
|
public async Task<IActionResult> Inventory(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||||
|
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
|
||||||
|
{
|
||||||
|
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
|
||||||
|
var error = DashboardReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||||
|
|
||||||
|
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||||
|
|
||||||
|
public record SalesKpis(
|
||||||
|
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||||
|
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||||
|
|
||||||
|
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||||
|
|
||||||
|
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||||
|
|
||||||
|
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||||
|
|
||||||
|
public record SalesSummaryResponse(
|
||||||
|
DateTime From, DateTime To, string Granularity,
|
||||||
|
SalesKpiComparison Kpis, List<TrendPoint> Trend,
|
||||||
|
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||||
|
|
||||||
|
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||||
|
|
||||||
|
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
|
||||||
|
|
||||||
|
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
|
||||||
|
|
||||||
|
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
|
||||||
|
|
||||||
|
public record InventorySummaryResponse(
|
||||||
|
DateTime From, DateTime To, string Granularity,
|
||||||
|
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
|
||||||
|
{
|
||||||
|
private sealed class SoldLine
|
||||||
|
{
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public Guid PoId { get; set; }
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public decimal? Cost { get; set; }
|
||||||
|
public Guid CategoryId { get; set; }
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public Guid RegionId { get; set; }
|
||||||
|
public string RegionName { get; set; } = "";
|
||||||
|
public Guid RepId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
||||||
|
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||||
|
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q =
|
||||||
|
from li in db.ShopPOItems.AsNoTracking()
|
||||||
|
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||||
|
where inv.Status == InvoiceStatus.Finalized
|
||||||
|
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
||||||
|
select new SoldLine
|
||||||
|
{
|
||||||
|
Date = inv.FinalizedAt!.Value.Date,
|
||||||
|
PoId = li.FK_ShopPOId,
|
||||||
|
Value = li.QuantityFulfilled * li.UnitPrice,
|
||||||
|
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
||||||
|
CategoryId = li.Item.FK_CategoryId,
|
||||||
|
CategoryName = li.Item.Category.Name,
|
||||||
|
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
||||||
|
RegionName = li.ShopPO.Store.Route.Region.Name,
|
||||||
|
RepId = li.ShopPO.RaisedById,
|
||||||
|
};
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.SaleReturnItems.AsNoTracking()
|
||||||
|
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var lines = SoldLines(f);
|
||||||
|
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
|
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
|
||||||
|
var costed = lines.Where(l => l.Cost != null);
|
||||||
|
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
|
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||||
|
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||||
|
return new SalesTotals(gross, returns, orders, costedSales, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => l.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
|
||||||
|
.ToListAsync())
|
||||||
|
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
|
||||||
|
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
|
||||||
|
.ToListAsync())
|
||||||
|
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
|
||||||
|
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||||
|
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
|
||||||
|
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||||
|
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var rows = await SoldLines(f).GroupBy(l => l.RepId)
|
||||||
|
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
|
||||||
|
var ids = rows.Select(r => r.RepId).ToList();
|
||||||
|
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.FullName);
|
||||||
|
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<StockBalance> Balances(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var q = db.StockBalances.AsNoTracking().AsQueryable();
|
||||||
|
if (f.WarehouseId.HasValue)
|
||||||
|
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value = quantity x cost price; items without a cost price contribute quantity only.
|
||||||
|
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
|
||||||
|
(await Balances(f).GroupBy(b => b.Item.Category.Name)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Category = g.Key,
|
||||||
|
Quantity = g.Sum(x => x.Quantity),
|
||||||
|
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Value).ToListAsync())
|
||||||
|
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
|
||||||
|
.GroupBy(b => b.LocationId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Id = g.Key,
|
||||||
|
Quantity = g.Sum(x => x.Quantity),
|
||||||
|
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
var ids = rows.Select(r => r.Id).ToList();
|
||||||
|
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
|
||||||
|
.ToDictionaryAsync(w => w.Id, w => w.Name);
|
||||||
|
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
|
||||||
|
.OrderByDescending(r => r.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
|
||||||
|
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
|
||||||
|
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.StockMovements.AsNoTracking()
|
||||||
|
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
|
||||||
|
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|
||||||
|
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
if (f.WarehouseId.HasValue)
|
||||||
|
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
|
||||||
|
|
||||||
|
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
|
||||||
|
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
|
||||||
|
.ToListAsync();
|
||||||
|
return rows.GroupBy(r => r.Date)
|
||||||
|
.Select(g => (g.Key,
|
||||||
|
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
|
||||||
|
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public class DashboardReportService(IDashboardReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 366;
|
||||||
|
private const int DailyGranularityMaxDays = 62;
|
||||||
|
|
||||||
|
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-29)).Date;
|
||||||
|
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Validate(DashboardFilter f)
|
||||||
|
{
|
||||||
|
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.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||||
|
|
||||||
|
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
|
||||||
|
|
||||||
|
private static SalesKpis ToKpis(SalesTotals t)
|
||||||
|
{
|
||||||
|
var net = t.GrossSales - t.ReturnValue;
|
||||||
|
var gp = t.CostedSales - t.CostOfSales;
|
||||||
|
return new SalesKpis(
|
||||||
|
t.GrossSales, t.ReturnValue, net, t.Orders,
|
||||||
|
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||||
|
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var days = (f.To - f.From).Days + 1;
|
||||||
|
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
|
||||||
|
var daily = Daily(f);
|
||||||
|
|
||||||
|
var current = await repo.GetSalesTotalsAsync(f);
|
||||||
|
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||||
|
var sales = await repo.GetDailySalesAsync(f);
|
||||||
|
var returns = await repo.GetDailyReturnsAsync(f);
|
||||||
|
|
||||||
|
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
|
||||||
|
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
|
||||||
|
.GroupBy(x => Bucket(x.Date, daily))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
|
||||||
|
return new SalesSummaryResponse(
|
||||||
|
f.From, f.To, daily ? "day" : "month",
|
||||||
|
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||||
|
trend,
|
||||||
|
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||||
|
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||||
|
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var daily = Daily(f);
|
||||||
|
var movements = (await repo.GetDailyMovementsAsync(f))
|
||||||
|
.GroupBy(m => Bucket(m.Date, daily))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var rec = g.Sum(x => x.Received);
|
||||||
|
var iss = g.Sum(x => x.Issued);
|
||||||
|
return new MovementPoint(g.Key, rec, iss, rec - iss);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new InventorySummaryResponse(
|
||||||
|
f.From, f.To, daily ? "day" : "month",
|
||||||
|
await repo.GetStockValueByCategoryAsync(f),
|
||||||
|
await repo.GetStockByWarehouseAsync(f),
|
||||||
|
movements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||||
|
|
||||||
|
public interface IDashboardReportRepository
|
||||||
|
{
|
||||||
|
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||||
|
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||||
|
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
|
||||||
|
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
|
||||||
|
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
|
||||||
|
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -6,7 +7,7 @@ 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")]
|
||||||
[Authorize("delivery-task.manage")]
|
[Authorize("delivery-task.manage")]
|
||||||
@@ -68,7 +69,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);
|
||||||
|
|||||||
@@ -48,7 +48,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 }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -58,10 +58,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,34 +92,78 @@ 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);
|
||||||
@@ -104,6 +171,9 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
|||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +194,22 @@ 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 tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
|
||||||
|
if (tracked && request.ReferenceId is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
|
||||||
|
|
||||||
|
List<StockMovement> movements;
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (tracked)
|
||||||
|
{
|
||||||
|
movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity,
|
||||||
|
request.ReferenceId!.Value, userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var movement = new StockMovement
|
var movement = new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = request.ItemId,
|
FK_ItemId = request.ItemId,
|
||||||
@@ -216,63 +222,61 @@ public class StockService(
|
|||||||
ReferenceId = request.ReferenceId,
|
ReferenceId = request.ReferenceId,
|
||||||
CreatedBy = userId
|
CreatedBy = userId
|
||||||
};
|
};
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
|
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
|
||||||
await repo.AddMovementAsync(movement);
|
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.");
|
|
||||||
|
List<StockMovement> movements;
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request.Quantity < 0)
|
||||||
|
{
|
||||||
|
movements = await mover.MoveAsync(request.ItemId, item.Name,
|
||||||
|
request.LocationId, request.LocationType,
|
||||||
|
ExternalLocationId, LocationType.External,
|
||||||
|
-request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
|
||||||
|
if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment.");
|
||||||
|
|
||||||
var movement = new StockMovement
|
var movement = new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = request.ItemId,
|
FK_ItemId = request.ItemId,
|
||||||
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
|
FromLocationId = ExternalLocationId,
|
||||||
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
|
FromLocationType = LocationType.External,
|
||||||
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
|
ToLocationId = request.LocationId,
|
||||||
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
|
ToLocationType = request.LocationType,
|
||||||
Quantity = Math.Abs(request.Quantity),
|
Quantity = request.Quantity,
|
||||||
Type = MovementType.Adjustment,
|
Type = MovementType.Adjustment,
|
||||||
ReasonCode = request.ReasonCode,
|
ReasonCode = request.ReasonCode,
|
||||||
CreatedBy = userId
|
CreatedBy = userId
|
||||||
};
|
};
|
||||||
|
await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity);
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (balance is null)
|
|
||||||
{
|
|
||||||
await repo.AddBalanceAsync(new StockBalance
|
|
||||||
{
|
|
||||||
FK_ItemId = request.ItemId,
|
|
||||||
LocationId = request.LocationId,
|
|
||||||
LocationType = request.LocationType,
|
|
||||||
Quantity = request.Quantity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
balance.Quantity += request.Quantity;
|
|
||||||
balance.UpdatedAt = DateTime.Now;
|
|
||||||
repo.UpdateBalance(balance);
|
|
||||||
}
|
|
||||||
await repo.AddMovementAsync(movement);
|
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, 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;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
repo.UpdateMovement(movement);
|
repo.UpdateMovement(movement);
|
||||||
await uow.SaveChangesAsync(ct);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,5 @@ public interface IStoreRepository
|
|||||||
void Update(Models.Store entity);
|
void Update(Models.Store entity);
|
||||||
void Remove(Models.Store entity);
|
void Remove(Models.Store entity);
|
||||||
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
Task<List<Models.ShopPO>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
||||||
|
Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ public class StoreController(
|
|||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId;
|
var userId = currentUser.UserId;
|
||||||
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
|
||||||
return StatusCode(403);
|
// return StatusCode(403);
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,13 @@ public class StoreController(
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/analytics")]
|
||||||
|
public async Task<IActionResult> GetAnalytics(Guid id, [FromQuery] int months = 12, [FromQuery] int overdueDays = 30)
|
||||||
|
{
|
||||||
|
var result = await service.GetAnalyticsAsync(id, months, overdueDays);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/status")]
|
[HttpPatch("{id:guid}/status")]
|
||||||
[Authorize("store.manage")]
|
[Authorize("store.manage")]
|
||||||
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
|||||||
public record PatchStoreStatusRequest(bool IsActive);
|
public record PatchStoreStatusRequest(bool IsActive);
|
||||||
|
|
||||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||||
|
|
||||||
|
public record StoreAnalyticsSummary(
|
||||||
|
decimal TotalSales,
|
||||||
|
decimal TotalCollected,
|
||||||
|
decimal Outstanding,
|
||||||
|
decimal OverdueAmount,
|
||||||
|
int OverdueInvoiceCount,
|
||||||
|
int InvoiceCount,
|
||||||
|
decimal AverageOrderValue,
|
||||||
|
DateTime? LastSaleDate,
|
||||||
|
decimal CreditLimit,
|
||||||
|
decimal CreditUtilizationPercent);
|
||||||
|
|
||||||
|
public record StoreMonthlySales(string Month, decimal Invoiced, decimal Collected, decimal Outstanding);
|
||||||
|
|
||||||
|
public record StoreTopProduct(Guid ItemId, string Name, int Quantity, decimal Revenue, DateTime LastSoldAt);
|
||||||
|
|
||||||
|
public record StoreAgeingBucket(string Label, decimal Amount, int InvoiceCount);
|
||||||
|
|
||||||
|
public record StoreAnalyticsInvoice(
|
||||||
|
Guid InvoiceId,
|
||||||
|
DateTime InvoiceDate,
|
||||||
|
DateTime DueDate,
|
||||||
|
decimal TotalAmount,
|
||||||
|
decimal PaidAmount,
|
||||||
|
decimal Balance,
|
||||||
|
string PaymentStatus,
|
||||||
|
int DaysOverdue);
|
||||||
|
|
||||||
|
public record StoreAnalyticsResponse(
|
||||||
|
Guid StoreId,
|
||||||
|
int OverdueDays,
|
||||||
|
StoreAnalyticsSummary Summary,
|
||||||
|
List<StoreMonthlySales> MonthlySales,
|
||||||
|
List<StoreTopProduct> TopProducts,
|
||||||
|
List<StoreAgeingBucket> Ageing,
|
||||||
|
List<StoreAnalyticsInvoice> Invoices);
|
||||||
|
|||||||
@@ -51,4 +51,12 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
|||||||
await context.ShopPOs
|
await context.ShopPOs
|
||||||
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Models.Invoice>> GetInvoicesForAnalyticsAsync(Guid storeId) =>
|
||||||
|
await context.Invoices
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(i => i.Payments)
|
||||||
|
.Include(i => i.ShopPO).ThenInclude(p => p.Items).ThenInclude(x => x.Item)
|
||||||
|
.Where(i => i.IsActive && i.Status != InvoiceStatus.Draft && i.ShopPO.FK_StoreId == storeId)
|
||||||
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,4 +253,92 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
|||||||
orders.Sum(o => o.TotalAmount),
|
orders.Sum(o => o.TotalAmount),
|
||||||
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
|
orders.Count == 0 ? null : orders.Max(o => o.CreatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<StoreAnalyticsResponse?> GetAnalyticsAsync(Guid id, int months, int overdueDays)
|
||||||
|
{
|
||||||
|
var store = await repo.GetByIdAsync(id);
|
||||||
|
if (store is null) return null;
|
||||||
|
if (!await HasStoreScopeAsync(id)) return null;
|
||||||
|
|
||||||
|
months = Math.Clamp(months, 1, 36);
|
||||||
|
overdueDays = Math.Clamp(overdueDays, 0, 365);
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var from = new DateTime(now.Year, now.Month, 1).AddMonths(-(months - 1));
|
||||||
|
|
||||||
|
var invoices = (await repo.GetInvoicesForAnalyticsAsync(id))
|
||||||
|
.Select(i =>
|
||||||
|
{
|
||||||
|
var date = i.FinalizedAt ?? i.CreatedAt;
|
||||||
|
var paid = i.Payments.Where(p => p.IsActive).Sum(p => p.Amount);
|
||||||
|
var balance = Math.Max(0, i.TotalAmount - paid);
|
||||||
|
var due = date.AddDays(overdueDays);
|
||||||
|
var daysOverdue = balance > 0 && now > due ? (int)(now - due).TotalDays : 0;
|
||||||
|
return (Invoice: i, Date: date, Due: due, Paid: paid, Balance: balance, DaysOverdue: daysOverdue);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var inRange = invoices.Where(x => x.Date >= from).ToList();
|
||||||
|
var totalSales = inRange.Sum(x => x.Invoice.TotalAmount);
|
||||||
|
var overdue = invoices.Where(x => x.DaysOverdue > 0).ToList();
|
||||||
|
|
||||||
|
var summary = new StoreAnalyticsSummary(
|
||||||
|
totalSales,
|
||||||
|
inRange.Sum(x => x.Paid),
|
||||||
|
invoices.Sum(x => x.Balance),
|
||||||
|
overdue.Sum(x => x.Balance),
|
||||||
|
overdue.Count,
|
||||||
|
inRange.Count,
|
||||||
|
inRange.Count == 0 ? 0 : Math.Round(totalSales / inRange.Count, 2),
|
||||||
|
invoices.Count == 0 ? null : invoices.Max(x => x.Date),
|
||||||
|
store.CreditLimit,
|
||||||
|
store.CreditLimit > 0 ? Math.Round(store.OutstandingBalance / store.CreditLimit * 100, 1) : 0);
|
||||||
|
|
||||||
|
var monthly = Enumerable.Range(0, months)
|
||||||
|
.Select(n => from.AddMonths(n))
|
||||||
|
.Select(m => new StoreMonthlySales(
|
||||||
|
m.ToString("yyyy-MM"),
|
||||||
|
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Invoice.TotalAmount),
|
||||||
|
inRange.Where(x => x.Date.Year == m.Year && x.Date.Month == m.Month).Sum(x => x.Paid),
|
||||||
|
invoices
|
||||||
|
.Where(x => x.Date < m.AddMonths(1))
|
||||||
|
.Sum(x => Math.Max(0, x.Invoice.TotalAmount - x.Invoice.Payments.Where(p => p.IsActive && p.CollectedAt < m.AddMonths(1)).Sum(p => p.Amount)))))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var topProducts = inRange
|
||||||
|
.SelectMany(x => x.Invoice.ShopPO.Items.Select(it => (it, x.Date)))
|
||||||
|
.GroupBy(t => t.it.FK_ItemId)
|
||||||
|
.Select(g => new StoreTopProduct(
|
||||||
|
g.Key,
|
||||||
|
g.First().it.Item?.Name ?? "Unknown",
|
||||||
|
g.Sum(t => t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity),
|
||||||
|
g.Sum(t => (t.it.QuantityFulfilled > 0 ? t.it.QuantityFulfilled : t.it.Quantity) * t.it.UnitPrice),
|
||||||
|
g.Max(t => t.Date)))
|
||||||
|
.OrderByDescending(p => p.Revenue)
|
||||||
|
.Take(10)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var unpaid = invoices.Where(x => x.Balance > 0).ToList();
|
||||||
|
StoreAgeingBucket Bucket(string label, Func<int, bool> match)
|
||||||
|
{
|
||||||
|
var items = unpaid.Where(x => match(x.DaysOverdue)).ToList();
|
||||||
|
return new StoreAgeingBucket(label, items.Sum(x => x.Balance), items.Count);
|
||||||
|
}
|
||||||
|
var ageing = new List<StoreAgeingBucket>
|
||||||
|
{
|
||||||
|
Bucket("Current", d => d <= 0),
|
||||||
|
Bucket("1-30", d => d is >= 1 and <= 30),
|
||||||
|
Bucket("31-60", d => d is >= 31 and <= 60),
|
||||||
|
Bucket("61-90", d => d is >= 61 and <= 90),
|
||||||
|
Bucket("90+", d => d > 90)
|
||||||
|
};
|
||||||
|
|
||||||
|
var list = invoices
|
||||||
|
.OrderByDescending(x => x.Date)
|
||||||
|
.Select(x => new StoreAnalyticsInvoice(
|
||||||
|
x.Invoice.Id, x.Date, x.Due, x.Invoice.TotalAmount, x.Paid, x.Balance,
|
||||||
|
x.Invoice.PaymentStatus.ToString(), x.DaysOverdue))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new StoreAnalyticsResponse(id, overdueDays, summary, monthly, topProducts, ageing, list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/role")]
|
|
||||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await service.AssignRoleAsync(id, request, ct);
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}/scope")]
|
[HttpPut("{id:guid}/scope")]
|
||||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ public record CreateUserRequest(
|
|||||||
Guid RoleId,
|
Guid RoleId,
|
||||||
Guid? ReportsToUserId);
|
Guid? ReportsToUserId);
|
||||||
|
|
||||||
public record UpdateUserRequest(string FullName);
|
public record UpdateUserRequest(
|
||||||
|
string FullName,
|
||||||
|
string? Username = null,
|
||||||
|
Guid? RoleId = null,
|
||||||
|
string? CurrentPassword = null,
|
||||||
|
string? NewPassword = null);
|
||||||
|
|
||||||
public record AssignUserRoleRequest(Guid RoleId);
|
public record AssignUserRoleRequest(Guid RoleId);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
|||||||
email = request.Username,
|
email = request.Username,
|
||||||
fullname = request.FullName,
|
fullname = request.FullName,
|
||||||
password = request.Password,
|
password = request.Password,
|
||||||
roleId = request.RoleId,
|
roleId = request.RoleId.ToString(),
|
||||||
userTypeId = "00000001-0000-0000-0000-200000000000",
|
userTypeId = "00000001-0000-0000-0000-200000000000",
|
||||||
sendCredentialsEmail = false
|
sendCredentialsEmail = false
|
||||||
}, reference: user.Id.ToString(), ct: ct);
|
}, reference: user.Id.ToString(), ct: ct);
|
||||||
@@ -69,12 +69,52 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
|||||||
{
|
{
|
||||||
var user = await repo.GetByIdAsync(id);
|
var user = await repo.GetByIdAsync(id);
|
||||||
if (user is null || !user.IsActive) return null;
|
if (user is null || !user.IsActive) return null;
|
||||||
|
|
||||||
user.FullName = request.FullName;
|
user.FullName = request.FullName;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Username))
|
||||||
|
user.Username = request.Username;
|
||||||
|
if (request.RoleId.HasValue)
|
||||||
|
user.FK_RoleId = request.RoleId.Value;
|
||||||
user.UpdatedAt = DateTime.Now;
|
user.UpdatedAt = DateTime.Now;
|
||||||
|
|
||||||
await uow.BeginAsync(ct);
|
await uow.BeginAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
repo.Update(user);
|
repo.Update(user);
|
||||||
|
|
||||||
|
var hasUsernameChange = !string.IsNullOrWhiteSpace(request.Username);
|
||||||
|
var hasRoleChange = request.RoleId.HasValue;
|
||||||
|
|
||||||
|
if (hasUsernameChange || hasRoleChange)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object> { ["userId"] = user.Id.ToString() };
|
||||||
|
if (hasUsernameChange) payload["userName"] = request.Username!;
|
||||||
|
if (hasRoleChange) payload["roleCode"] = request.RoleId!.Value.ToString();
|
||||||
|
|
||||||
|
var authResult = await authHex.CallAsync("UpdateUser", payload, reference: user.Id.ToString(), ct: ct);
|
||||||
|
if (!authResult.Success)
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw new InvalidOperationException($"AuthHex update failed: {authResult.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CurrentPassword) && !string.IsNullOrWhiteSpace(request.NewPassword))
|
||||||
|
{
|
||||||
|
var authResult = await authHex.CallAsync("ChangeUserPassword", new
|
||||||
|
{
|
||||||
|
userId = user.Id.ToString(),
|
||||||
|
currentPassword = request.CurrentPassword,
|
||||||
|
newPassword = request.NewPassword
|
||||||
|
}, reference: user.Id.ToString(), ct: ct);
|
||||||
|
|
||||||
|
if (!authResult.Success)
|
||||||
|
{
|
||||||
|
await uow.RollbackAsync(ct);
|
||||||
|
throw new InvalidOperationException($"AuthHex password change failed: {authResult.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await uow.CommitAsync(ct);
|
await uow.CommitAsync(ct);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -87,22 +127,9 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
|
|||||||
|
|
||||||
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
|
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var user = await repo.GetByIdAsync(id);
|
return await UpdateAsync(id, new UpdateUserRequest(
|
||||||
if (user is null || !user.IsActive) return null;
|
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
|
||||||
user.FK_RoleId = request.RoleId;
|
RoleId: request.RoleId), ct);
|
||||||
user.UpdatedAt = DateTime.Now;
|
|
||||||
await uow.BeginAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
repo.Update(user);
|
|
||||||
await uow.CommitAsync(ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await uow.RollbackAsync(ct);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
return ToResponse(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
|
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,10 @@ using Distribution.Features.Sync;
|
|||||||
using Distribution.Features.Uom;
|
using Distribution.Features.Uom;
|
||||||
using Distribution.Features.User;
|
using Distribution.Features.User;
|
||||||
using Distribution.Features.Vehicle;
|
using Distribution.Features.Vehicle;
|
||||||
|
using Distribution.Features.DashboardReport;
|
||||||
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;
|
||||||
@@ -54,8 +56,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>();
|
||||||
@@ -68,6 +72,10 @@ 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<IDashboardReportRepository, DashboardReportRepository>();
|
||||||
|
builder.Services.AddScoped<DashboardReportService>();
|
||||||
|
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>();
|
||||||
@@ -88,8 +96,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>();
|
||||||
@@ -101,6 +111,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>();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:6703"
|
"BaseUrl": "http://localhost:6705"
|
||||||
},
|
},
|
||||||
"StoreManagement": {
|
"StoreManagement": {
|
||||||
"UnverifiedKycOrderThreshold": 0
|
"UnverifiedKycOrderThreshold": 0
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, type FormEvent } from 'react'
|
import { useState, useEffect, type FormEvent } from 'react'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { Space_Grotesk } from 'next/font/google'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import { getApiError } from '@/lib/api'
|
import { getApiError } from '@/lib/api'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const sg = Space_Grotesk({ subsets: ['latin'], weight: ['400', '500', '600', '700'] })
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login, user, loading } = useAuth()
|
const { login, user, loading } = useAuth()
|
||||||
@@ -15,11 +21,10 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) router.replace('/')
|
||||||
router.replace('/')
|
|
||||||
}
|
|
||||||
}, [user, loading, router])
|
}, [user, loading, router])
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
@@ -28,55 +33,169 @@ export default function LoginPage() {
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await login(identifier, password)
|
await login(identifier, password)
|
||||||
|
toast.add({ title: 'Signed in successfully', type: 'success' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getApiError(err))
|
const message = getApiError(err)
|
||||||
|
setError(message)
|
||||||
|
toast.add({ title: 'Sign in failed', description: message, type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center bg-background">
|
<div className={`${sg.className} min-h-svh flex flex-col md:flex-row`}>
|
||||||
<form onSubmit={handleSubmit} className="flex w-full max-w-sm flex-col gap-6 p-6">
|
|
||||||
<div className="flex flex-col gap-1 text-center">
|
{/* ── Left panel: image (hidden on mobile, 60% on desktop) ── */}
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Sign in</h1>
|
<div className="relative hidden md:flex md:w-[60%] shrink-0 flex-col">
|
||||||
<p className="text-sm text-muted-foreground">Distribution ERP</p>
|
<Image
|
||||||
|
src="/HexaDistribution.png"
|
||||||
|
alt="Distribution warehouse"
|
||||||
|
fill
|
||||||
|
sizes="60vw"
|
||||||
|
className="object-cover object-center"
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logo overlay top-left */}
|
||||||
|
<div className="relative z-10 px-10 pt-9">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||||
|
alt="HexDive"
|
||||||
|
className="h-7 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-[15px] tracking-wide text-black">
|
||||||
|
HexaDistro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
{/* Gradient at bottom */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.2) 40%, transparent 70%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text content pinned to bottom-left */}
|
||||||
|
<div className="relative z-10 mt-auto px-12 pb-12">
|
||||||
|
<h1
|
||||||
|
className="font-bold leading-tight mb-3"
|
||||||
|
style={{ fontSize: 'clamp(28px, 3vw, 46px)', color: '#ffffff' }}
|
||||||
|
>
|
||||||
|
Your operations.<br />
|
||||||
|
<span
|
||||||
|
className="inline-block px-2.5 py-1 mt-1 rounded-sm"
|
||||||
|
style={{ backgroundColor: '#1A1033', color: '#ffffff' }}
|
||||||
|
>
|
||||||
|
Fully in control.
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.72)', fontSize: 15, lineHeight: 1.65, maxWidth: 380 }}>
|
||||||
|
Distribution ERP built for teams that deliver right, every time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right panel: login card ── */}
|
||||||
|
<div className="flex-1 bg-white flex flex-col items-center justify-center px-6 py-10 sm:px-10">
|
||||||
|
|
||||||
|
{/* Mobile-only logo */}
|
||||||
|
<div className="flex items-center gap-2.5 mb-8 md:hidden">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||||
|
alt="HexDive"
|
||||||
|
className="h-7 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
|
||||||
|
HexaDistro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-full rounded-2xl bg-white px-6 py-8 sm:px-10 sm:py-11"
|
||||||
|
style={{
|
||||||
|
maxWidth: 440,
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo — desktop only inside card */}
|
||||||
|
<div className="hidden md:flex items-center gap-2.5 mb-8">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||||
|
alt="HexDive"
|
||||||
|
className="h-7 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-[14px] tracking-widest uppercase text-gray-900">
|
||||||
|
HexaDistro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h1 className="text-3xl font-semibold text-gray-900 mb-1">Sign in</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-8">
|
||||||
|
Enter your credentials to access your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="identifier">Email</Label>
|
<Label htmlFor="identifier" className="text-gray-700">
|
||||||
|
Username or Email
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="identifier"
|
id="identifier"
|
||||||
type="email"
|
type="text"
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
onChange={e => setIdentifier(e.target.value)}
|
||||||
|
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password" className="text-gray-700">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="border-gray-300 bg-white text-gray-900 placeholder:text-gray-400 pr-10"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(v => !v)}
|
||||||
|
className="absolute inset-y-0 right-2.5 flex items-center text-gray-400 hover:text-gray-600"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting} className="w-full">
|
<Button type="submit" disabled={submitting} className="w-full mt-2">
|
||||||
{submitting ? 'Signing in…' : 'Sign in'}
|
{submitting ? 'Signing in…' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||||
|
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from: string // yyyy-MM-dd
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
const fmtNum = (v: number) => v.toLocaleString('en-LK', { maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
function ChartCard({ title, note, children, className = '' }: { title: string; note?: string; children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex h-full flex-col rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md ${className}`}>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
|
<span className="h-3.5 w-1 rounded-full bg-primary" />
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{note && <div className="mt-0.5 text-[11px] text-muted-foreground">{note}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-52 min-h-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Delta({ current, previous }: { current: number; previous: number }) {
|
||||||
|
if (previous === 0) return <span className="text-[10px] text-muted-foreground">No previous-period data</span>
|
||||||
|
const pct = ((current - previous) / Math.abs(previous)) * 100
|
||||||
|
const up = pct >= 0
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${up ? 'bg-foreground/10 text-foreground' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{up ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">vs prev.</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ title, value, footer }: { title: string; value: string; footer?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
|
||||||
|
<div>
|
||||||
|
<div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
|
||||||
|
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
{footer && <div className="truncate text-[10px] leading-tight">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3" aria-busy="true">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={`h-[262px] rounded-xl ${i % 3 === 0 ? 'md:col-span-2' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLoad<T>(load: () => Promise<T>, deps: unknown[], failTitle: string) {
|
||||||
|
const key = JSON.stringify(deps)
|
||||||
|
const [result, setResult] = useState<{ key: string; data: T | null } | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
load()
|
||||||
|
.then(data => { if (!cancelled) setResult({ key, data }) })
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return
|
||||||
|
toast.add({ title: failTitle, description: getApiError(err), type: 'error' })
|
||||||
|
setResult({ key, data: null })
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key])
|
||||||
|
// Loading until the response for the *current* deps has arrived, so a changed range shows the skeleton again.
|
||||||
|
return { data: result?.key === key ? result.data : null, loading: result?.key !== key }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return <p className="py-6 text-center text-xs text-muted-foreground">{text}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranked list with an inline share bar — friendlier than a bar chart for "top N" comparisons. */
|
||||||
|
function RankTable({ rows, fmt, empty, barClass = 'bg-primary' }: {
|
||||||
|
rows: Array<{ name: string; value: number; extra?: string }>
|
||||||
|
fmt: (v: number) => string
|
||||||
|
empty: string
|
||||||
|
barClass?: string
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0 || rows.every(r => r.value === 0)) return <Empty text={empty} />
|
||||||
|
const max = Math.max(...rows.map(r => r.value), 1)
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto pr-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={`${r.name}-${i}`} className="border-b border-muted last:border-0">
|
||||||
|
<td className="w-5 py-1.5 text-[10px] font-semibold text-muted-foreground">{i + 1}</td>
|
||||||
|
<td className="max-w-0 truncate py-1.5 pr-2 font-medium text-foreground" title={r.name}>
|
||||||
|
<div className="truncate">{r.name}</div>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={`h-full rounded-full ${barClass}`} style={{ width: `${(r.value / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-1.5 pl-1 text-right font-semibold tabular-nums text-foreground">
|
||||||
|
{fmt(r.value)}
|
||||||
|
{r.extra && <div className="text-[10px] font-normal text-muted-foreground">{r.extra}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({ cols, rows, empty }: { cols: string[]; rows: ReactNode[][]; empty: string }) {
|
||||||
|
if (rows.length === 0) return <Empty text={empty} />
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="border-b text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{cols.map((c, i) => <th key={c} className={`py-1.5 font-semibold ${i === 0 ? 'text-left' : 'text-right'}`}>{c}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, ri) => (
|
||||||
|
<tr key={ri} className="border-b border-muted last:border-0 hover:bg-muted/40">
|
||||||
|
{r.map((cell, ci) => (
|
||||||
|
<td key={ci} className={`py-1.5 tabular-nums ${ci === 0 ? 'max-w-0 truncate pr-2 text-left font-medium text-foreground' : 'text-right text-muted-foreground'}`}>{cell}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const named = (rows: Array<{ name: string; amount: number }>) => rows.map(r => ({ name: r.name, value: r.amount }))
|
||||||
|
|
||||||
|
/** Top (n-1) slices plus an "Other" slice, so donuts stay readable. */
|
||||||
|
function topWithOther(rows: Array<{ name: string; value: number }>, n = 5) {
|
||||||
|
const sorted = [...rows].filter(r => r.value > 0).sort((a, b) => b.value - a.value)
|
||||||
|
if (sorted.length <= n) return sorted
|
||||||
|
const head = sorted.slice(0, n - 1)
|
||||||
|
return [...head, { name: 'Other', value: sorted.slice(n - 1).reduce((t, r) => t + r.value, 0) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharePct = (v: number, total: number) => (total > 0 ? `${((v / total) * 100).toFixed(1)}%` : '—')
|
||||||
|
|
||||||
|
export function SalesSection({ range }: { range: DateRange }) {
|
||||||
|
const { data: sales, loading } = useLoad<DashboardSalesSummary>(
|
||||||
|
() => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics')
|
||||||
|
const { data: products, loading: productsLoading } = useLoad<ProductSalesRow[]>(
|
||||||
|
() => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)),
|
||||||
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
|
const { current: c, previous: p } = sales.kpis
|
||||||
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
|
<Kpi
|
||||||
|
title={title}
|
||||||
|
value={money ? fmtMoney(c[key] as number) : fmtNum(c[key] as number)}
|
||||||
|
footer={<Delta current={c[key] as number} previous={p[key] as number} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||||
|
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
||||||
|
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{kpi('Net Sales', 'netSales')}
|
||||||
|
{kpi('Gross Sales', 'grossSales')}
|
||||||
|
{kpi('Total Orders', 'orders', false)}
|
||||||
|
{kpi('Average Order Value', 'averageOrderValue')}
|
||||||
|
<Kpi
|
||||||
|
title="Gross Profit"
|
||||||
|
value={fmtMoney(c.grossProfit)}
|
||||||
|
footer={
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{c.grossProfitMarginPercent === null ? 'Margin n/a (no cost prices)' : `${c.grossProfitMarginPercent}% margin`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{kpi('Sales Returns', 'returnValue')}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
||||||
|
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||||
|
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Top-Selling Products" note="Top 10 by sales value" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Product', 'Qty', 'Sales', 'Margin']}
|
||||||
|
empty="No product sales in this period."
|
||||||
|
rows={(products ?? []).map(r => [
|
||||||
|
r.name,
|
||||||
|
fmtNum(r.qtyFulfilled),
|
||||||
|
fmtMoney(r.salesValue),
|
||||||
|
r.marginPercent === null ? '—' : (
|
||||||
|
<span className={r.marginPercent >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}>{r.marginPercent}%</span>
|
||||||
|
),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Region">
|
||||||
|
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart
|
||||||
|
data={trend}
|
||||||
|
series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }, { key: 'returns', label: 'Returns', color: '#f87171' }]}
|
||||||
|
emptyText="No sales or returns in this period."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Representative" note="Top 20; rep = user who raised the order">
|
||||||
|
<RankTable rows={sales.byRep.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, repTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventorySection({ range }: { range: DateRange }) {
|
||||||
|
const { data: inv, loading } = useLoad<DashboardInventorySummary>(
|
||||||
|
() => dashboardReports.inventory(range), [range.from, range.to], 'Failed to load inventory analytics')
|
||||||
|
const { data: overview } = useLoad<StockOverview>(
|
||||||
|
() => stockReports.overview(), [], 'Failed to load stock overview')
|
||||||
|
const { data: lowStock } = useLoad(
|
||||||
|
() => productReports.stock({ lowStockOnly: true, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
|
[], 'Failed to load low-stock products')
|
||||||
|
const { data: movers, loading: moversLoading } = useLoad<ProductSalesRow[]>(
|
||||||
|
() => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
|
if (loading || moversLoading || !inv || !overview) return <LoadingGrid />
|
||||||
|
|
||||||
|
const healthy = Math.max(0, overview.totalSkus - overview.lowStockCount - overview.outOfStockCount)
|
||||||
|
const sold = (movers ?? []).map(r => ({ name: r.name, value: r.qtyFulfilled }))
|
||||||
|
const fast = sold.slice(0, 3)
|
||||||
|
const slow = sold.slice(-3).reverse()
|
||||||
|
const catTotal = inv.valueByCategory.reduce((t, r) => t + r.value, 0)
|
||||||
|
const lowRows = (lowStock ?? [])
|
||||||
|
.filter(r => r.reorderLevel)
|
||||||
|
.sort((a, b) => a.warehouseQty / (a.reorderLevel ?? 1) - b.warehouseQty / (b.reorderLevel ?? 1))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
<Kpi title="Active Products" value={fmtNum(overview.totalSkus)} />
|
||||||
|
<Kpi title="Stock on Hand" value={fmtNum(overview.totalQty)} />
|
||||||
|
<Kpi title="Inventory Value" value={fmtMoney(overview.totalValue)} footer={<span className="text-[10px] text-muted-foreground">At cost price</span>} />
|
||||||
|
<Kpi title="Low-Stock Products" value={fmtNum(overview.lowStockCount)} />
|
||||||
|
<Kpi title="Out-of-Stock Products" value={fmtNum(overview.outOfStockCount)} />
|
||||||
|
<Kpi title="Expiring Soon (30 days)" value={fmtNum(overview.expiringSoonCount)} footer={<span className="text-[10px] text-muted-foreground">Batches · {fmtNum(overview.expiredCount)} already expired</span>} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Reserved stock, excess stock, ageing, discrepancies and pending transfers are not tracked yet, so they are not shown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ChartCard title="Stock Movement Trend" note={`Quantity per ${inv.granularity}. Received = purchases + customer returns; issued = sales + purchase returns + damages`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart
|
||||||
|
data={inv.movementTrend}
|
||||||
|
series={[{ key: 'received', label: 'Received', color: seriesColor(0) }, { key: 'issued', label: 'Issued', color: seriesColor(1) }]}
|
||||||
|
emptyText="No stock movements in this period."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Stock Health" note="Active products">
|
||||||
|
<DonutChart
|
||||||
|
data={[
|
||||||
|
{ name: 'Healthy', value: healthy },
|
||||||
|
{ name: 'Low stock', value: overview.lowStockCount },
|
||||||
|
{ name: 'Out of stock', value: overview.outOfStockCount },
|
||||||
|
]}
|
||||||
|
centerLabel="products"
|
||||||
|
emptyText="No active products."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Warehouse Stock" note="Quantity and value at cost price" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Warehouse', 'Quantity', 'Value']}
|
||||||
|
empty="No warehouse stock recorded."
|
||||||
|
rows={inv.byWarehouse.map(r => [r.name, fmtNum(r.quantity), fmtMoney(r.value)])}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Inventory Value by Category" note="At cost price">
|
||||||
|
<RankTable
|
||||||
|
rows={inv.valueByCategory.map(r => ({ name: r.category, value: r.value, extra: sharePct(r.value, catTotal) }))}
|
||||||
|
fmt={fmtMoney} empty="No stock recorded."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Low-Stock Products" note="Lowest coverage of reorder level first" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Product', 'On hand', 'Reorder at', 'Coverage']}
|
||||||
|
empty="No products below their reorder level."
|
||||||
|
rows={lowRows.map(r => {
|
||||||
|
const cov = Math.round((r.warehouseQty / (r.reorderLevel ?? 1)) * 100)
|
||||||
|
return [
|
||||||
|
r.name,
|
||||||
|
fmtNum(r.warehouseQty),
|
||||||
|
fmtNum(r.reorderLevel ?? 0),
|
||||||
|
<span key="c" className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${cov < 50 ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'}`}>{cov}%</span>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Fast & Slow Movers" note="Top and bottom 3 by quantity sold (among top 50 sellers)">
|
||||||
|
<div className="flex h-full flex-col gap-1 overflow-auto">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Fast-moving</div>
|
||||||
|
<RankTable rows={fast} fmt={fmtNum} empty="No sales in this period." />
|
||||||
|
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Slow-moving</div>
|
||||||
|
<RankTable rows={slow} fmt={fmtNum} empty="No sales in this period." />
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, Line, LineChart, 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)', label = 'Quantity' }: { data: ChartDatum[]; emptyText: string; color?: string; label?: string }) {
|
||||||
|
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
|
||||||
|
|
||||||
|
const config = { value: { label, 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)']
|
||||||
|
export const seriesColor = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length]
|
||||||
|
|
||||||
|
/** Multi-series line chart over a categorical x axis (day or month labels). */
|
||||||
|
export function MultiLineChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-48 w-full">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grouped (side-by-side) column chart comparing several series per category. */
|
||||||
|
export function GroupedColumnChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-48 w-full">
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={16} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Bar key={s.key} dataKey={s.key} fill={`var(--color-${s.key})`} radius={3} maxBarSize={24} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-series smooth area chart with a soft gradient under each line. */
|
||||||
|
export function MultiAreaChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-40 w-full">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
{series.map(s => (
|
||||||
|
<linearGradient key={s.key} id={`area-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.45} />
|
||||||
|
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.03} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} tick={{ fontSize: 10 }} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} fill={`url(#area-${s.key})`} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
|
||||||
import { Add01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
|
|
||||||
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
|
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
|
||||||
import type { Layout, LayoutItem } from 'react-grid-layout'
|
import type { LayoutItem } from 'react-grid-layout'
|
||||||
import 'react-grid-layout/css/styles.css'
|
import 'react-grid-layout/css/styles.css'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import {
|
import {
|
||||||
@@ -23,16 +19,18 @@ import {
|
|||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||||
|
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||||
|
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||||
|
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||||
|
|
||||||
const STORAGE_KEY = 'distribution-dashboard-widgets-v3'
|
const ROW_HEIGHT = 26
|
||||||
const ROW_HEIGHT = 36
|
const GRID_MARGIN: [number, number] = [10, 10]
|
||||||
const GRID_MARGIN: [number, number] = [16, 16]
|
|
||||||
|
|
||||||
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
|
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
|
||||||
@@ -66,6 +64,7 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
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
|
||||||
@@ -73,28 +72,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: 5, h: 6, minW: 3, 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: 3, h: 6, minW: 2, 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 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,54 +129,6 @@ function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFlowLayouts(ids: string[]): Record<Breakpoint, LayoutItem[]> {
|
|
||||||
return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record<Breakpoint, LayoutItem[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Places a newly-added widget below everything else on a given breakpoint, so it never overlaps what's already there. */
|
|
||||||
function appendWidget(layout: LayoutItem[], meta: WidgetMeta, bp: Breakpoint): LayoutItem[] {
|
|
||||||
const cfg = SIZE_CONFIG[bp][meta.size]
|
|
||||||
const y = layout.reduce((max, item) => Math.max(max, item.y + item.h), 0)
|
|
||||||
return [...layout, { i: meta.id, x: 0, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH }]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardLayoutState {
|
|
||||||
activeIds: string[]
|
|
||||||
layouts: Record<Breakpoint, LayoutItem[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultLayoutState(): DashboardLayoutState {
|
|
||||||
return { activeIds: [...DEFAULT_ACTIVE], layouts: generateFlowLayouts(DEFAULT_ACTIVE) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStoredLayoutState(): DashboardLayoutState {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return defaultLayoutState()
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
const validIds = new Set(WIDGET_META.map(w => w.id))
|
|
||||||
const activeIds: string[] = Array.isArray(parsed?.activeIds)
|
|
||||||
? parsed.activeIds.filter((id: unknown): id is string => typeof id === 'string' && validIds.has(id))
|
|
||||||
: []
|
|
||||||
if (activeIds.length === 0) return defaultLayoutState()
|
|
||||||
|
|
||||||
const layouts = {} as Record<Breakpoint, LayoutItem[]>
|
|
||||||
for (const bp of BREAKPOINT_ORDER) {
|
|
||||||
const storedLayout: LayoutItem[] = Array.isArray(parsed?.layouts?.[bp])
|
|
||||||
? parsed.layouts[bp].filter((l: unknown): l is LayoutItem =>
|
|
||||||
!!l && typeof l === 'object' && typeof (l as LayoutItem).i === 'string' && validIds.has((l as LayoutItem).i) && activeIds.includes((l as LayoutItem).i))
|
|
||||||
: []
|
|
||||||
const missingIds = activeIds.filter(id => !storedLayout.some(l => l.i === id))
|
|
||||||
layouts[bp] = missingIds.length > 0 ? [...storedLayout, ...generateFlowLayout(missingIds, bp)] : storedLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeIds, layouts }
|
|
||||||
} catch {
|
|
||||||
return defaultLayoutState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
||||||
|
|
||||||
interface OpenRide {
|
interface OpenRide {
|
||||||
@@ -200,6 +161,10 @@ interface DashboardData {
|
|||||||
draftInvoiceCount: number
|
draftInvoiceCount: number
|
||||||
printedInvoiceCount: number
|
printedInvoiceCount: number
|
||||||
finalizedInvoiceCount: number
|
finalizedInvoiceCount: number
|
||||||
|
poStatusChart: ChartDatum[]
|
||||||
|
paymentStatusChart: ChartDatum[]
|
||||||
|
movementTypeChart: ChartDatum[]
|
||||||
|
monthlySales: MonthlySalesPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyDashboardData(): DashboardData {
|
function emptyDashboardData(): DashboardData {
|
||||||
@@ -211,6 +176,7 @@ function emptyDashboardData(): DashboardData {
|
|||||||
lowStock: [], movements: [], warehouseBalances: [],
|
lowStock: [], movements: [], warehouseBalances: [],
|
||||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||||
|
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,19 +299,50 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryPill({ cat }: { cat: Category }) {
|
const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus']
|
||||||
const meta = CATEGORY_META[cat]
|
|
||||||
|
function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
|
<div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
|
||||||
{meta.label}
|
<div>
|
||||||
</span>
|
<div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
|
||||||
|
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{sub && <div className="truncate text-[10px] text-muted-foreground">{sub}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bar({ pct, colorClass }: { pct: number; colorClass: string }) {
|
function StatStrip({ ids, data }: { ids: string[]; data: DashboardData }) {
|
||||||
|
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
|
||||||
|
const invTotal = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
|
||||||
|
const seg = (n: number) => (invTotal > 0 ? `${(n / invTotal) * 100}%` : '0%')
|
||||||
|
const tiles: Record<string, React.ReactNode> = {
|
||||||
|
pendingPO: <StatTile title="Pending Shop POs" value={String(data.pendingPOCount)} sub={`${data.totalPOCount} total orders`} />,
|
||||||
|
awaitingConfirm: <StatTile title="Awaiting Confirmation" value={String(data.awaitingConfirmCount)} sub="Confirmed, not yet loaded" />,
|
||||||
|
fulfillRate: (
|
||||||
|
<StatTile title="Fulfillment Rate" value={`${pct}%`} sub={`${data.fulfilledPOCount} of ${data.totalPOCount} fulfilled`}>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} /></div>
|
||||||
|
</StatTile>
|
||||||
|
),
|
||||||
|
deliveryTasks: <StatTile title="Pending Delivery Tasks" value={String(data.pendingTasksCount)} sub={`${data.partialTasksCount} partially delivered`} />,
|
||||||
|
outstandingPay: <StatTile title="Outstanding Payments" value={fmtMoney(data.outstandingAmount)} sub={`${data.outstandingInvoiceCount} invoices`} />,
|
||||||
|
invoiceStatus: (
|
||||||
|
<StatTile title="Invoices by Status" value={String(invTotal)} sub={`${data.draftInvoiceCount} draft · ${data.printedInvoiceCount} printed · ${data.finalizedInvoiceCount} final`}>
|
||||||
|
<div className="mt-1 flex h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="bg-primary/30" style={{ width: seg(data.draftInvoiceCount) }} />
|
||||||
|
<div className="bg-primary/60" style={{ width: seg(data.printedInvoiceCount) }} />
|
||||||
|
<div className="bg-primary" style={{ width: seg(data.finalizedInvoiceCount) }} />
|
||||||
|
</div>
|
||||||
|
</StatTile>
|
||||||
|
),
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
|
{STAT_IDS.filter(id => ids.includes(id)).map(id => <div key={id}>{tiles[id]}</div>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -355,26 +352,20 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
case 'pendingPO':
|
case 'pendingPO':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.pendingPOCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.pendingPOCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'awaitingConfirm':
|
case 'awaitingConfirm':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
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 ? (
|
||||||
@@ -387,38 +378,25 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
|
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
|
||||||
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
|
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-violet-600 px-2.5 py-1 text-[11px] font-semibold text-white">Open</span>
|
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300">Open</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.pendingTasksCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.pendingTasksCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -458,88 +436,90 @@ 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>
|
||||||
<div className="text-3xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
|
<div className="text-2xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
|
||||||
<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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeLayoutsChange(
|
function toIsoDate(d: Date) {
|
||||||
full: Record<Breakpoint, LayoutItem[]>,
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
changed: Partial<Record<Breakpoint, Layout>>,
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
): Record<Breakpoint, LayoutItem[]> {
|
}
|
||||||
const next = { ...full }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) {
|
type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom'
|
||||||
const changedArr = changed[bp]
|
|
||||||
if (!changedArr) continue
|
const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [
|
||||||
next[bp] = full[bp].map(item => {
|
{ key: '7d', label: 'Last 7 days' },
|
||||||
const updated = changedArr.find(c => c.i === item.i)
|
{ key: '1m', label: '1 month' },
|
||||||
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
|
{ key: '3m', label: '3 months' },
|
||||||
})
|
{ key: '1y', label: '1 year' },
|
||||||
}
|
{ key: 'custom', label: 'Custom' },
|
||||||
return next
|
]
|
||||||
|
|
||||||
|
function presetRange(preset: Exclude<RangePreset, 'custom'>): DateRange {
|
||||||
|
const to = new Date()
|
||||||
|
const from = new Date()
|
||||||
|
if (preset === '7d') from.setDate(from.getDate() - 6)
|
||||||
|
else if (preset === '1m') from.setMonth(from.getMonth() - 1)
|
||||||
|
else if (preset === '3m') from.setMonth(from.getMonth() - 3)
|
||||||
|
else { from.setFullYear(from.getFullYear() - 1); from.setDate(from.getDate() + 1) }
|
||||||
|
return { from: toIsoDate(from), to: toIsoDate(to) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function minFromDate(to: string) {
|
||||||
|
const d = new Date(`${to}T00:00:00`)
|
||||||
|
d.setDate(d.getDate() - 365)
|
||||||
|
return toIsoDate(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardClient() {
|
export function DashboardClient() {
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const [data, setData] = useState<DashboardData | null>(null)
|
const [data, setData] = useState<DashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
|
const [range, setRange] = useState<DateRange>(() => presetRange('1m'))
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [preset, setPreset] = useState<RangePreset>('1m')
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
|
||||||
const [category, setCategory] = useState<'all' | Category>('all')
|
|
||||||
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
||||||
|
|
||||||
const allowedIds = useMemo(
|
const allowedIds = useMemo(
|
||||||
@@ -548,23 +528,10 @@ export function DashboardClient() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const permittedActiveIds = useMemo(
|
const permittedActiveIds = useMemo(
|
||||||
() => state.activeIds.filter(id => allowedIds.has(id)),
|
() => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)),
|
||||||
[state.activeIds, allowedIds],
|
[allowedIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setState(loadStoredLayoutState())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const persist = useCallback((next: DashboardLayoutState) => {
|
|
||||||
setState(next)
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable (private browsing, etc.) — layout just won't persist
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -580,118 +547,82 @@ export function DashboardClient() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
function toggleWidget(id: string, checked: boolean) {
|
|
||||||
if (checked) {
|
|
||||||
const meta = WIDGET_META.find(w => w.id === id)
|
|
||||||
if (!meta || state.activeIds.includes(id)) return
|
|
||||||
const layouts = { ...state.layouts }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) layouts[bp] = appendWidget(layouts[bp], meta, bp)
|
|
||||||
persist({ activeIds: [...state.activeIds, id], layouts })
|
|
||||||
} else {
|
|
||||||
const layouts = { ...state.layouts }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) layouts[bp] = layouts[bp].filter(l => l.i !== id)
|
|
||||||
persist({ activeIds: state.activeIds.filter(x => x !== id), layouts })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLayoutChange(_layout: Layout, changedLayouts: Partial<Record<Breakpoint, Layout>>) {
|
|
||||||
setState(prev => {
|
|
||||||
const next = { ...prev, layouts: mergeLayoutsChange(prev.layouts, changedLayouts) }
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — position just won't persist
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() => permittedActiveIds
|
() => permittedActiveIds
|
||||||
.map(id => WIDGET_META.find(w => w.id === id))
|
.map(id => WIDGET_META.find(w => w.id === id))
|
||||||
.filter((w): w is WidgetMeta => Boolean(w))
|
.filter((w): w is WidgetMeta => Boolean(w))
|
||||||
.filter(w => category === 'all' || w.cat === category),
|
.filter(w => !STAT_IDS.includes(w.id)),
|
||||||
[permittedActiveIds, category],
|
[permittedActiveIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleLayouts = useMemo(() => {
|
const visibleLayouts = useMemo(() => {
|
||||||
const visibleIds = new Set(visibleWidgets.map(w => w.id))
|
const visibleIds = new Set(visibleWidgets.map(w => w.id))
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
BREAKPOINT_ORDER.map(bp => [bp, state.layouts[bp].filter(l => visibleIds.has(l.i))]),
|
BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(Array.from(visibleIds), bp)]),
|
||||||
) as Record<Breakpoint, LayoutItem[]>
|
) as Record<Breakpoint, LayoutItem[]>
|
||||||
}, [state.layouts, visibleWidgets])
|
}, [visibleWidgets])
|
||||||
|
|
||||||
const filters: Array<{ key: 'all' | Category; label: string }> = [
|
const canSales = hasPermission('orders.view')
|
||||||
{ key: 'all', label: 'All widgets' },
|
const canInventory = hasPermission('stock.view')
|
||||||
{ key: 'sales', label: CATEGORY_META.sales.label },
|
|
||||||
{ key: 'ops', label: CATEGORY_META.ops.label },
|
|
||||||
{ key: 'stock', label: CATEGORY_META.stock.label },
|
|
||||||
{ key: 'finance', label: CATEGORY_META.finance.label },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
<h1 className="text-xl font-semibold text-foreground">Dashboard</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{editMode ? 'Drag to move, drag a corner to resize, or tick which widgets show.' : 'Add, remove, drag and resize widgets to make this view yours.'}
|
Sales, inventory, shop orders, rides and loading sheets, deliveries, stock alerts, invoices and payments
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
{editMode && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowPicker(v => !v)}>
|
|
||||||
<HugeiconsIcon icon={Add01Icon} size={14} />
|
|
||||||
Widgets
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant={editMode ? 'default' : 'outline'} size="sm" onClick={() => { setEditMode(v => !v); setShowPicker(false) }}>
|
|
||||||
{editMode ? 'Done' : 'Customize Dashboard'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showPicker && (
|
|
||||||
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-2xl border bg-popover p-3 shadow-lg">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-semibold text-foreground">Widgets</span>
|
|
||||||
<button onClick={() => setShowPicker(false)} aria-label="Close" className="rounded-full bg-muted p-1 text-muted-foreground">
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => {
|
|
||||||
const checked = state.activeIds.includes(w.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={w.id}
|
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Checkbox checked={checked} onCheckedChange={(v) => toggleWidget(w.id, v === true)} />
|
|
||||||
<span className="text-sm text-foreground">{w.title}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{(canSales || canInventory) && (
|
||||||
{filters.map(f => (
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<span />
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<div className="inline-flex rounded-lg border bg-muted/50 p-0.5">
|
||||||
|
{RANGE_PRESETS.map(p => (
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
key={p.key}
|
||||||
onClick={() => setCategory(f.key)}
|
onClick={() => { setPreset(p.key); if (p.key !== 'custom') setRange(presetRange(p.key)) }}
|
||||||
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
category === f.key
|
preset === p.key ? 'bg-primary text-primary-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
||||||
? 'border-foreground bg-foreground text-background'
|
|
||||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="date" value={range.from} min={minFromDate(range.to)} max={range.to} aria-label="From date"
|
||||||
|
onChange={e => e.target.value && setRange(r => ({ ...r, from: e.target.value }))}
|
||||||
|
className="rounded-lg border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">to</span>
|
||||||
|
<input
|
||||||
|
type="date" value={range.to} min={range.from} max={toIsoDate(new Date())} aria-label="To date"
|
||||||
|
onChange={e => e.target.value && setRange(r => ({ ...r, to: e.target.value }))}
|
||||||
|
className="rounded-lg border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canSales && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Sales</h3>
|
||||||
|
<SalesSection range={range} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canInventory && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Inventory</h3>
|
||||||
|
<InventorySection range={range} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading || !data ? (
|
{loading || !data ? (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -699,11 +630,9 @@ export function DashboardClient() {
|
|||||||
<Skeleton key={i} className="h-40 rounded-2xl" />
|
<Skeleton key={i} className="h-40 rounded-2xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : visibleWidgets.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
||||||
No widgets on this dashboard yet. Click “Widgets” to add some.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<StatStrip ids={permittedActiveIds} data={data} />
|
||||||
<div ref={containerRef} className="w-full">
|
<div ref={containerRef} className="w-full">
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<ResponsiveGridLayout
|
<ResponsiveGridLayout
|
||||||
@@ -715,32 +644,28 @@ export function DashboardClient() {
|
|||||||
margin={GRID_MARGIN}
|
margin={GRID_MARGIN}
|
||||||
containerPadding={[0, 0]}
|
containerPadding={[0, 0]}
|
||||||
maxRows={Infinity}
|
maxRows={Infinity}
|
||||||
dragConfig={{ enabled: editMode }}
|
dragConfig={{ enabled: false }}
|
||||||
resizeConfig={{ enabled: editMode, handles: ['se'] }}
|
resizeConfig={{ enabled: false }}
|
||||||
autoSize
|
autoSize
|
||||||
onLayoutChange={handleLayoutChange}
|
|
||||||
>
|
>
|
||||||
{visibleWidgets.map(w => (
|
{visibleWidgets.map(w => (
|
||||||
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
|
<div key={w.id} className="relative flex flex-col overflow-auto rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md">
|
||||||
{editMode && (
|
<div className="mb-2 shrink-0">
|
||||||
<button
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
onClick={() => toggleWidget(w.id, false)}
|
<span className="h-3.5 w-1 rounded-full bg-primary" />
|
||||||
aria-label="Remove widget"
|
{w.title}
|
||||||
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="mb-3.5 shrink-0">
|
|
||||||
<div className="text-sm font-semibold text-foreground">{w.title}</div>
|
|
||||||
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
<WidgetBody id={w.id} data={data} />
|
<WidgetBody id={w.id} data={data} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ResponsiveGridLayout>
|
</ResponsiveGridLayout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function DriverSheet({ open, onClose, driver, onSuccess }: DriverSheetPro
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
usersApi.list({ pageSize: 100 }).then((res) => setUsersList(getPaginatedItems(res))).catch((err) =>
|
usersApi.list().then((res) => setUsersList(res)).catch((err) =>
|
||||||
toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav'
|
import { BottomNav } from '@/components/layout/bottom-nav'
|
||||||
|
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -13,6 +14,9 @@ export default function DashboardLayout({
|
|||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
<SidebarTrigger className="-ml-1 hidden md:flex" />
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+2
-2
@@ -89,11 +89,11 @@ export function RegionManageEntrySheet({ open, onClose, onSuccess, initial }: Re
|
|||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
regionsApi.list({ page: 1, pageSize: 200 }),
|
regionsApi.list({ page: 1, pageSize: 200 }),
|
||||||
usersApi.list({ page: 1, pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([r, u]) => {
|
.then(([r, u]) => {
|
||||||
setRegionsList(getPaginatedItems(r).filter((x) => x.isActive))
|
setRegionsList(getPaginatedItems(r).filter((x) => x.isActive))
|
||||||
setAgencyUsers(getPaginatedItems(u).filter((x) => x.roleName === 'RegionalAgency'))
|
setAgencyUsers(u.filter((x) => x.roleName === 'RegionalAgency'))
|
||||||
})
|
})
|
||||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||||
}, [open, initial])
|
}, [open, initial])
|
||||||
|
|||||||
+2
-2
@@ -81,8 +81,8 @@ export function RegionManageListClient() {
|
|||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usersApi.list({ page: 1, pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setAgencyUsers(getPaginatedItems(res)))
|
.then((res) => setAgencyUsers(res))
|
||||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -45,13 +45,13 @@ export function RegionManagerDialog({ open, onClose, region }: RegionManagerDial
|
|||||||
setCurrent(null)
|
setCurrent(null)
|
||||||
Promise.all([
|
Promise.all([
|
||||||
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
|
||||||
usersApi.list({ pageSize: 500 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([manager, userRes]) => {
|
.then(([manager, userRes]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setCurrent(manager)
|
setCurrent(manager)
|
||||||
setSelected(manager?.userId ?? '')
|
setSelected(manager?.userId ?? '')
|
||||||
setCandidates(getPaginatedItems(userRes).filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
setCandidates(userRes.filter((u) => u.isActive && u.roleName === MANAGER_ROLE))
|
||||||
})
|
})
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
.finally(() => !cancelled && setLoading(false))
|
.finally(() => !cancelled && setLoading(false))
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManag
|
|||||||
setOwnerUserId(route.ownerUserId ?? '')
|
setOwnerUserId(route.ownerUserId ?? '')
|
||||||
setStoreFilter('')
|
setStoreFilter('')
|
||||||
|
|
||||||
usersApi.list({ page: 1, pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep')))
|
.then((res) => setSalesReps(res.filter((u) => u.roleName === 'SalesRep')))
|
||||||
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
|
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
|
||||||
|
|
||||||
storesApi.list({ page: 1, pageSize: 200 })
|
storesApi.list({ page: 1, pageSize: 200 })
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ export function RoutesClient() {
|
|||||||
}, [fetchData, page, pageSize])
|
}, [fetchData, page, pageSize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usersApi.list({ page: 1, pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
for (const u of getPaginatedItems(res)) map[u.id] = u.fullName
|
for (const u of res) map[u.id] = u.fullName
|
||||||
setUserNameById(map)
|
setUserNameById(map)
|
||||||
})
|
})
|
||||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+236
@@ -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 />
|
||||||
|
}
|
||||||
+217
@@ -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',
|
||||||
|
}
|
||||||
+153
@@ -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>
|
||||||
|
}
|
||||||
+226
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+235
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-1
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+2
-2
@@ -48,8 +48,8 @@ export function ConfirmLoadingSheetDialog({ open, onClose, onSuccess, sheet }: C
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ confirmedByUserId: '' })
|
form.reset({ confirmedByUserId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ function LoadingSheetForm() {
|
|||||||
regionsApi.list({ pageSize: 200 }),
|
regionsApi.list({ pageSize: 200 }),
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
ridesApi.list({ status: isEdit ? undefined : 'Open', pageSize: 200 }),
|
ridesApi.list({ status: isEdit ? undefined : 'Open', pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
itemsApi.list({ pageSize: 500 }),
|
itemsApi.list({ pageSize: 500 }),
|
||||||
])
|
])
|
||||||
.then(([regionsRes, warehousesRes, ridesRes, usersRes, itemsRes]) => {
|
.then(([regionsRes, warehousesRes, ridesRes, usersRes, itemsRes]) => {
|
||||||
@@ -176,7 +176,7 @@ function LoadingSheetForm() {
|
|||||||
setRegionsList(getPaginatedItems(regionsRes))
|
setRegionsList(getPaginatedItems(regionsRes))
|
||||||
setWarehousesList(getPaginatedItems(warehousesRes))
|
setWarehousesList(getPaginatedItems(warehousesRes))
|
||||||
setRidesList(getPaginatedItems(ridesRes))
|
setRidesList(getPaginatedItems(ridesRes))
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(usersRes)
|
||||||
})
|
})
|
||||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||||
}, [isEdit])
|
}, [isEdit])
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ export default function NewGrnPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
warehousesApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
suppliersApi.list(),
|
suppliersApi.list(),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||||
])
|
])
|
||||||
.then(([ws, us, ss, its, pos]) => {
|
.then(([ws, us, ss, its, pos]) => {
|
||||||
setWarehousesList(getPaginatedItems(ws))
|
setWarehousesList(getPaginatedItems(ws))
|
||||||
setUsersList(getPaginatedItems(us))
|
setUsersList(us)
|
||||||
setSuppliersList(ss)
|
setSuppliersList(ss)
|
||||||
setItemsList(getPaginatedItems(its))
|
setItemsList(getPaginatedItems(its))
|
||||||
// Only POs that can still receive goods
|
// Only POs that can still receive goods
|
||||||
|
|||||||
+2
-2
@@ -66,8 +66,8 @@ export function PurchaseReturnDialog({ open, onClose, onSuccess, order, existing
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ reason: '', createdByUserId: '', items: [defaultItem()] })
|
form.reset({ reason: '', createdByUserId: '', items: [defaultItem()] })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,12 @@ export default function NewPurchaseOrderPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
suppliersApi.list(),
|
suppliersApi.list(),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([suppliersRes, itemsRes, usersRes]) => {
|
.then(([suppliersRes, itemsRes, usersRes]) => {
|
||||||
setSuppliersList(suppliersRes)
|
setSuppliersList(suppliersRes)
|
||||||
setItemsList(getPaginatedItems(itemsRes))
|
setItemsList(getPaginatedItems(itemsRes))
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(usersRes)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
|
|||||||
+67
@@ -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 />
|
||||||
|
}
|
||||||
+2
-2
@@ -50,8 +50,8 @@ export function ClaimSettleDialog({ open, onClose, onSuccess, claim }: ClaimSett
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ settlementAmount: 0, settledByUserId: '' })
|
form.reset({ settlementAmount: 0, settledByUserId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export function ClaimSheet({ open, onClose, onSuccess, claim }: ClaimSheetProps)
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ agencyUserId: claim?.agencyUserId ?? '', details: claim?.details ?? '' })
|
form.reset({ agencyUserId: claim?.agencyUserId ?? '', details: claim?.details ?? '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open, claim])
|
}, [open, claim])
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ export function ClaimsClient() {
|
|||||||
try {
|
try {
|
||||||
const [claimsList, usersRes] = await Promise.all([
|
const [claimsList, usersRes] = await Promise.all([
|
||||||
claimsApi.list(),
|
claimsApi.list(),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
setUserNameById(new Map(getPaginatedItems(usersRes).map((u) => [u.id, u.fullName])))
|
setUserNameById(new Map(usersRes.map((u) => [u.id, u.fullName])))
|
||||||
setData(claimsList)
|
setData(claimsList)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load claims', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load claims', description: getApiError(err), type: 'error' })
|
||||||
|
|||||||
+2
-2
@@ -88,12 +88,12 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
shopPosApi.list({ pageSize: 200 }),
|
shopPosApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([posRes, itemsRes, usersRes]) => {
|
.then(([posRes, itemsRes, usersRes]) => {
|
||||||
setShopPOList(getPaginatedItems(posRes))
|
setShopPOList(getPaginatedItems(posRes))
|
||||||
setItemsList(getPaginatedItems(itemsRes))
|
setItemsList(getPaginatedItems(itemsRes))
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(usersRes)
|
||||||
})
|
})
|
||||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|||||||
+2
-2
@@ -84,12 +84,12 @@ export function SupplierClaimDialog({ open, onClose, onSuccess, claim }: Supplie
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
Promise.all([
|
Promise.all([
|
||||||
purchaseOrdersApi.list({ pageSize: 200 }),
|
purchaseOrdersApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([posRes, usersRes]) => {
|
.then(([posRes, usersRes]) => {
|
||||||
const orderList = getPaginatedItems(posRes)
|
const orderList = getPaginatedItems(posRes)
|
||||||
setOrdersList(orderList)
|
setOrdersList(orderList)
|
||||||
setUsersList(getPaginatedItems(usersRes))
|
setUsersList(usersRes)
|
||||||
|
|
||||||
if (claim) {
|
if (claim) {
|
||||||
form.reset({
|
form.reset({
|
||||||
|
|||||||
+2
-2
@@ -57,8 +57,8 @@ export function SupplierClaimResolveDialog({ open, onClose, onSuccess, claim, mo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' })
|
form.reset({ resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open, mode])
|
}, [open, mode])
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function SalesVisitsClient() {
|
|||||||
routesApi.list({ page: 1, pageSize: 200 })
|
routesApi.list({ page: 1, pageSize: 200 })
|
||||||
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
|
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
|
||||||
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
|
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ export default function EditShopPOPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([shopPosApi.get(id), itemsApi.list({ pageSize: 200 }), usersApi.list({ pageSize: 200 })])
|
Promise.all([shopPosApi.get(id), itemsApi.list({ pageSize: 200 }), usersApi.list()])
|
||||||
.then(([poRes, items, users]) => {
|
.then(([poRes, items, users]) => {
|
||||||
setPo(poRes)
|
setPo(poRes)
|
||||||
setItemsList(getPaginatedItems(items))
|
setItemsList(getPaginatedItems(items))
|
||||||
setUsersList(getPaginatedItems(users))
|
setUsersList(users)
|
||||||
form.reset({
|
form.reset({
|
||||||
items: poRes.items
|
items: poRes.items
|
||||||
.filter((i) => i.isActive)
|
.filter((i) => i.isActive)
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function CancelShopPODialog({ open, onClose, onSuccess, po }: CancelShopP
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ cancelledByUserId: '', reason: '' })
|
form.reset({ cancelledByUserId: '', reason: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ confirmedByUserId: '' })
|
form.reset({ confirmedByUserId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ export function SalesClient() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
routesApi.list({ pageSize: 200 }),
|
routesApi.list({ pageSize: 200 }),
|
||||||
storesApi.list({ pageSize: 200 }),
|
storesApi.list({ pageSize: 200 }),
|
||||||
])
|
])
|
||||||
.then(([u, r, s]) => {
|
.then(([u, r, s]) => {
|
||||||
setAgentsList(getPaginatedItems(u))
|
setAgentsList(u)
|
||||||
setRoutesList(getPaginatedItems(r))
|
setRoutesList(getPaginatedItems(r))
|
||||||
setStoresList(getPaginatedItems(s))
|
setStoresList(getPaginatedItems(s))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export default function NewShopPOPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
storesApi.list({ pageSize: 200 }),
|
storesApi.list({ pageSize: 200 }),
|
||||||
itemsApi.list({ pageSize: 200 }),
|
itemsApi.list({ pageSize: 200 }),
|
||||||
usersApi.list({ pageSize: 200 }),
|
usersApi.list(),
|
||||||
])
|
])
|
||||||
.then(([stores, items, users]) => {
|
.then(([stores, items, users]) => {
|
||||||
setStoresList(getPaginatedItems(stores))
|
setStoresList(getPaginatedItems(stores))
|
||||||
setItemsList(getPaginatedItems(items))
|
setItemsList(getPaginatedItems(items))
|
||||||
setUsersList(getPaginatedItems(users))
|
setUsersList(users)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ userId: '' })
|
form.reset({ userId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export function StockAdjustDialog({ open, onClose, onSuccess, balance }: StockAd
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
form.reset({ quantity: 0, reasonCode: '', adjustedByUserId: '' })
|
form.reset({ quantity: 0, reasonCode: '', adjustedByUserId: '' })
|
||||||
usersApi.list({ pageSize: 200 })
|
usersApi.list()
|
||||||
.then((res) => setUsersList(getPaginatedItems(res)))
|
.then((res) => setUsersList(res))
|
||||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ export function StockBalancesClient() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await stockApi.balances({ page: p, pageSize: ps })
|
const res = await stockApi.balances({ page: p, pageSize: ps })
|
||||||
setData(getPaginatedItems(res))
|
const items = getPaginatedItems(res)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = items.filter(r => {
|
||||||
|
const k = `${r.itemId}-${r.locationId}`
|
||||||
|
if (seen.has(k)) return false
|
||||||
|
seen.add(k)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
setData(deduped)
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
|
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -11,36 +10,21 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { StoreResponse, RouteResponse } from '@/types/api'
|
import type { StoreResponse, RouteResponse } from '@/types/api'
|
||||||
|
|
||||||
const LocationPicker = dynamic(
|
import { StoreAnalytics } from '../_components/store-analytics'
|
||||||
() => import('../_components/location-picker').then((m) => m.LocationPicker),
|
|
||||||
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
|
|
||||||
)
|
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
const fmtMoney = (v: number) =>
|
|
||||||
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
|
||||||
|
|
||||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 py-3 border-b last:border-0">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||||
<span className="text-sm font-medium text-foreground">{children}</span>
|
<span className="text-sm font-medium text-foreground">{children}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border p-4">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-1 text-xl font-semibold text-foreground">{value}</p>
|
|
||||||
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StoreViewPage() {
|
export default function StoreViewPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -66,7 +50,6 @@ export default function StoreViewPage() {
|
|||||||
|
|
||||||
const routeName = store ? routesList.find((r) => r.id === store.routeId)?.routeName : undefined
|
const routeName = store ? routesList.find((r) => r.id === store.routeId)?.routeName : undefined
|
||||||
|
|
||||||
const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -103,73 +86,26 @@ export default function StoreViewPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body skeleton */}
|
{loading && <div className="h-20 animate-pulse rounded-xl bg-muted" />}
|
||||||
{loading && (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
<div className="space-y-2 lg:col-span-1">
|
|
||||||
{Array.from({ length: 7 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-12 animate-pulse rounded-xl bg-muted" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-20 animate-pulse rounded-2xl bg-muted" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
{!loading && store && (
|
{!loading && store && (
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{/* Left — store details */}
|
<DetailRow label="Owner">{store.ownerName ?? '—'}</DetailRow>
|
||||||
<div className="lg:col-span-1">
|
<DetailRow label="Contact">{store.contactNumber ?? '—'}</DetailRow>
|
||||||
<div className="rounded-2xl border px-4">
|
|
||||||
<DetailRow label="Owner">{store.ownerName ?? "—"}</DetailRow>
|
|
||||||
<DetailRow label="Contact">{store.contactNumber ?? "—"}</DetailRow>
|
|
||||||
<DetailRow label="Address">{store.storeAddress}</DetailRow>
|
|
||||||
<DetailRow label="Route">
|
<DetailRow label="Route">
|
||||||
{routeName ?? <span className="font-mono text-xs">{store.routeId}</span>}
|
{routeName ?? <span className="font-mono text-xs">{store.routeId}</span>}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
{store.segment && (
|
<DetailRow label="Address">{store.storeAddress}</DetailRow>
|
||||||
<DetailRow label="Segment">{store.segment}</DetailRow>
|
<DetailRow label="Segment">{store.segment ?? '—'}</DetailRow>
|
||||||
)}
|
|
||||||
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
|
|
||||||
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
|
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — credit stats + map */}
|
|
||||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label="Credit Limit"
|
|
||||||
value={fmtMoney(store.creditLimit)}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Outstanding"
|
|
||||||
value={fmtMoney(store.outstandingBalance)}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Available Credit"
|
|
||||||
value={fmtMoney(availableCredit)}
|
|
||||||
sub={availableCredit < 0 ? 'Over limit' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LocationPicker
|
|
||||||
lat={store.latitude}
|
|
||||||
lng={store.longitude}
|
|
||||||
height={460}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Shop analytics */}
|
||||||
|
{!loading && store && <StoreAnalytics storeId={id} creditLimit={store.creditLimit} outstandingBalance={store.outstandingBalance} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Label,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
PolarAngleAxis,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
RadialBar,
|
||||||
|
RadialBarChart,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { stores as storesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { StoreAnalyticsResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const fmtMoney = (v: number) =>
|
||||||
|
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) =>
|
||||||
|
v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
|
||||||
|
const fmtDate = (v: string) =>
|
||||||
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
|
||||||
|
const fmtMonth = (v: string) =>
|
||||||
|
new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' })
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
const salesConfig = {
|
||||||
|
invoiced: { label: 'Invoiced', color: 'var(--chart-2)' },
|
||||||
|
collected: { label: 'Collected', color: 'var(--chart-4)' },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const trendConfig = {
|
||||||
|
invoiced: { label: "Invoiced", color: "var(--chart-2)" },
|
||||||
|
outstanding: { label: "Outstanding", color: "var(--destructive)" },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const creditConfig = {
|
||||||
|
used: { label: 'Credit used', color: 'var(--chart-2)' },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const AGEING_FILL: Record<string, { color: string; opacity: number }> = {
|
||||||
|
'Current': { color: 'var(--chart-2)', opacity: 1 },
|
||||||
|
'1-30': { color: 'var(--destructive)', opacity: 0.45 },
|
||||||
|
'31-60': { color: 'var(--destructive)', opacity: 0.65 },
|
||||||
|
'61-90': { color: 'var(--destructive)', opacity: 0.85 },
|
||||||
|
'90+': { color: 'var(--destructive)', opacity: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--muted-foreground)']
|
||||||
|
|
||||||
|
function DonutCenter({ title, value }: { title: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<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">{value}</tspan>
|
||||||
|
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 12} className="fill-muted-foreground text-[11px]">{title}</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, sub, danger }: { label: string; value: string; sub?: string; danger?: boolean }) {
|
||||||
|
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={danger ? "truncate text-base font-semibold leading-tight text-destructive" : "truncate text-base font-semibold leading-tight"}>{value}</span>
|
||||||
|
{sub && <span className="truncate text-[11px] text-muted-foreground">{sub}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreAnalytics({
|
||||||
|
storeId,
|
||||||
|
creditLimit,
|
||||||
|
outstandingBalance,
|
||||||
|
}: {
|
||||||
|
storeId: string
|
||||||
|
creditLimit: number
|
||||||
|
outstandingBalance: number
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<StoreAnalyticsResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storesApi.analytics(storeId)
|
||||||
|
.then(setData)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load analytics', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [storeId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-72 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
const { summary: s } = data
|
||||||
|
const availableCredit = creditLimit - outstandingBalance
|
||||||
|
const utilisationPct = creditLimit > 0 ? Math.round((outstandingBalance / creditLimit) * 1000) / 10 : 0
|
||||||
|
|
||||||
|
const ageingData = data.ageing.map((b) => ({ ...b, fill: AGEING_FILL[b.label]?.color, opacity: AGEING_FILL[b.label]?.opacity ?? 1 }))
|
||||||
|
const ageingTotal = data.ageing.reduce((sum, b) => sum + b.amount, 0)
|
||||||
|
const ageingConfig = Object.fromEntries(
|
||||||
|
data.ageing.map((b) => [b.label, { label: b.label === 'Current' ? 'Current' : `${b.label} days`, color: AGEING_FILL[b.label]?.color }])
|
||||||
|
) as ChartConfig
|
||||||
|
|
||||||
|
const topFive = data.topProducts.slice(0, 5)
|
||||||
|
const othersRevenue = data.topProducts.slice(5).reduce((sum, p) => sum + p.revenue, 0)
|
||||||
|
const productData = [
|
||||||
|
...topFive.map((p, i) => ({ name: p.name, revenue: p.revenue, fill: PRODUCT_COLORS[i] })),
|
||||||
|
...(othersRevenue > 0 ? [{ name: 'Others', revenue: othersRevenue, fill: PRODUCT_COLORS[5] }] : []),
|
||||||
|
]
|
||||||
|
const productTotal = productData.reduce((sum, p) => sum + p.revenue, 0)
|
||||||
|
const productConfig = Object.fromEntries(
|
||||||
|
productData.map((p) => [p.name, { label: p.name, color: p.fill }])
|
||||||
|
) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<Metric label="Total Sales (12m)" value={fmtMoney(s.totalSales)} sub={`${s.invoiceCount} invoices`} />
|
||||||
|
<Metric label="Collected" value={fmtMoney(s.totalCollected)} />
|
||||||
|
<Metric
|
||||||
|
label="Overdue"
|
||||||
|
value={fmtMoney(s.overdueAmount)}
|
||||||
|
sub={`${s.overdueInvoiceCount} invoice${s.overdueInvoiceCount === 1 ? '' : 's'} > ${data.overdueDays} days`}
|
||||||
|
danger={s.overdueAmount > 0}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Last Sale"
|
||||||
|
value={s.lastSaleDate ? fmtDate(s.lastSaleDate) : '—'}
|
||||||
|
sub={s.lastSaleDate ? daysAgo(s.lastSaleDate) : 'No sales yet'}
|
||||||
|
/>
|
||||||
|
<Metric label="Avg. Order Value" value={fmtMoney(s.averageOrderValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Credit</CardTitle>
|
||||||
|
<CardDescription>{utilisationPct}% of credit limit used</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative size-36">
|
||||||
|
<ChartContainer config={creditConfig} className="size-full">
|
||||||
|
<RadialBarChart
|
||||||
|
data={[{ used: Math.min(100, utilisationPct), fill: utilisationPct > 100 ? 'var(--destructive)' : 'var(--color-used)' }]}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
innerRadius="75%"
|
||||||
|
outerRadius="100%"
|
||||||
|
>
|
||||||
|
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||||
|
<RadialBar dataKey="used" background cornerRadius={10} />
|
||||||
|
</RadialBarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-lg font-semibold">{utilisationPct}%</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full grid-cols-3 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Limit</p>
|
||||||
|
<p className="truncate text-xs font-semibold">{fmtCompact(creditLimit)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Outstanding</p>
|
||||||
|
<p className="truncate text-xs font-semibold">{fmtCompact(outstandingBalance)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Available</p>
|
||||||
|
<p className={availableCredit < 0 ? 'truncate text-xs font-semibold text-destructive' : 'truncate text-xs font-semibold'}>
|
||||||
|
{fmtCompact(availableCredit)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly sales</CardTitle>
|
||||||
|
<CardDescription>Invoiced vs collected, last 12 months</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={salesConfig} className="h-56 w-full">
|
||||||
|
<AreaChart data={data.monthlySales}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillInvoiced" 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>
|
||||||
|
<linearGradient id="fillCollected" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-collected)" stopOpacity={0.5} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-collected)" 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(#fillInvoiced)" />
|
||||||
|
<Area dataKey="collected" type="monotone" stroke="var(--color-collected)" fill="url(#fillCollected)" />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sales vs outstanding</CardTitle>
|
||||||
|
<CardDescription>Monthly invoiced vs balance owed at month end</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={trendConfig} className="h-56 w-full">
|
||||||
|
<LineChart data={data.monthlySales}>
|
||||||
|
<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))} />} />
|
||||||
|
<Line dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" strokeWidth={2} dot={false} />
|
||||||
|
<Line dataKey="outstanding" type="monotone" stroke="var(--color-outstanding)" strokeWidth={2} dot={false} />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Receivables ageing</CardTitle>
|
||||||
|
<CardDescription>Unpaid balance by days overdue</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{ageingTotal === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No unpaid invoices.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={ageingConfig} className="h-56 w-full">
|
||||||
|
<BarChart data={ageingData}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="label" tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="label" />} />
|
||||||
|
<Bar dataKey="amount" radius={4} maxBarSize={36}>
|
||||||
|
{ageingData.map((b) => (
|
||||||
|
<Cell key={b.label} fill={b.fill} fillOpacity={b.opacity} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="products">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="products">Top products</TabsTrigger>
|
||||||
|
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="products">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top selling products</CardTitle>
|
||||||
|
<CardDescription>By revenue, last 12 months</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{data.topProducts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No product sales yet.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={productConfig} className="mx-auto h-64 w-full">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="name" />} />
|
||||||
|
<Pie data={productData} dataKey="revenue" nameKey="name" innerRadius={56} strokeWidth={2}>
|
||||||
|
<DonutCenter title="Revenue" value={fmtCompact(productTotal)} />
|
||||||
|
</Pie>
|
||||||
|
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead className="text-right">Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Revenue</TableHead>
|
||||||
|
<TableHead>Last sold</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.topProducts.map((p) => (
|
||||||
|
<TableRow key={p.itemId}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell className="text-right">{p.quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{fmtMoney(p.revenue)}</TableCell>
|
||||||
|
<TableCell>{fmtDate(p.lastSoldAt)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="invoices">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Due</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead className="text-right">Paid</TableHead>
|
||||||
|
<TableHead className="text-right">Balance</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.invoices.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground">No invoices yet.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{data.invoices.map((i) => (
|
||||||
|
<TableRow key={i.invoiceId}>
|
||||||
|
<TableCell className="font-mono text-xs">{i.invoiceId.slice(0, 8).toUpperCase()}</TableCell>
|
||||||
|
<TableCell>{fmtDate(i.invoiceDate)}</TableCell>
|
||||||
|
<TableCell>{fmtDate(i.dueDate)}</TableCell>
|
||||||
|
<TableCell className="text-right">{fmtMoney(i.totalAmount)}</TableCell>
|
||||||
|
<TableCell className="text-right">{fmtMoney(i.paidAmount)}</TableCell>
|
||||||
|
<TableCell className="text-right">{fmtMoney(i.balance)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{i.daysOverdue > 0 ? (
|
||||||
|
<Badge variant="destructive">{i.daysOverdue}d overdue</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant={i.paymentStatus === 'Paid' ? 'default' : 'secondary'}>{i.paymentStatus}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,10 +83,10 @@ export function UserSheet({ open, onClose, user, roles, users, onSuccess }: User
|
|||||||
|
|
||||||
async function onEditSubmit(values: EditValues) {
|
async function onEditSubmit(values: EditValues) {
|
||||||
try {
|
try {
|
||||||
await usersApi.update(user!.id, { fullName: values.fullName })
|
await usersApi.update(user!.id, {
|
||||||
if (values.roleId !== user!.roleId) {
|
fullName: values.fullName,
|
||||||
await usersApi.assignRole(user!.id, values.roleId)
|
roleId: values.roleId !== user!.roleId ? values.roleId : undefined,
|
||||||
}
|
})
|
||||||
toast.add({ title: 'User updated', type: 'success' })
|
toast.add({ title: 'User updated', type: 'success' })
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
|
import { RoleForm } from '../../_components/role-form'
|
||||||
|
|
||||||
|
export default function EditRolePage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
const [role, setRole] = useState<RoleResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rolesApi.get(id)
|
||||||
|
.then(setRole)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load role', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Edit Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Update role details and permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && role && <RoleForm role={role} onSuccess={goBack} onCancel={goBack} wide />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
||||||
|
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
||||||
|
|
||||||
|
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
||||||
|
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
||||||
|
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
||||||
|
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
||||||
|
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
||||||
|
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
||||||
|
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
||||||
|
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
level: z.string().min(1, 'Level is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
// 'orders.view' -> 'View Orders', 'loading-sheet.manage' -> 'Manage Loading Sheet'
|
||||||
|
function permissionLabel(code: string): string {
|
||||||
|
const [resource, action] = code.split('.')
|
||||||
|
const title = (s: string) => s.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
return action ? `${title(action)} ${title(resource)}` : title(resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleFormProps {
|
||||||
|
role: RoleResponse | null
|
||||||
|
onSuccess: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
wide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleForm({ role, onSuccess, onCancel, wide = false }: RoleFormProps) {
|
||||||
|
const isEdit = !!role
|
||||||
|
|
||||||
|
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', level: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
||||||
|
if (role) {
|
||||||
|
form.reset({ name: role.name, level: String(role.level) })
|
||||||
|
rolesApi.getPermissions(role.id)
|
||||||
|
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
||||||
|
.catch(() => {})
|
||||||
|
} else {
|
||||||
|
form.reset({ name: '', level: '' })
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
function togglePermission(id: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const body = { name: values.name, level: parseInt(values.level, 10) }
|
||||||
|
try {
|
||||||
|
let roleId: string
|
||||||
|
if (isEdit) {
|
||||||
|
await rolesApi.update(role!.id, body)
|
||||||
|
roleId = role!.id
|
||||||
|
toast.add({ title: 'Role updated', type: 'success' })
|
||||||
|
} else {
|
||||||
|
const created = await rolesApi.create(body)
|
||||||
|
roleId = created.id
|
||||||
|
toast.add({ title: 'Role created', type: 'success' })
|
||||||
|
}
|
||||||
|
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup className={wide ? 'grid grid-cols-1 gap-6 md:grid-cols-2' : undefined}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Role Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Sales Rep"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="level"
|
||||||
|
label="Level"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={String(l.value)}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{l.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{l.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{allPermissions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Permissions</p>
|
||||||
|
<div className={wide ? 'border rounded-md p-4' : 'border rounded-md max-h-[28rem] overflow-y-auto p-3'}>
|
||||||
|
<div className={wide ? 'flex flex-col gap-3' :'grid grid-cols-2 gap-3'}>
|
||||||
|
{PERMISSION_CATEGORIES.map((cat) => {
|
||||||
|
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
||||||
|
if (catPerms.length === 0) return null
|
||||||
|
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
||||||
|
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`cat-${cat.id}`}
|
||||||
|
checked={allChecked}
|
||||||
|
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
||||||
|
{cat.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={wide ? 'flex flex-row flex-wrap gap-x-6 gap-y-2 pl-6' : 'flex flex-col gap-1.5 pl-6'}>
|
||||||
|
{catPerms.map((p) => (
|
||||||
|
<div key={p.permissionId} className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={p.permissionId}
|
||||||
|
checked={selectedIds.has(p.permissionId)}
|
||||||
|
onCheckedChange={() => togglePermission(p.permissionId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
||||||
|
<span className="font-medium">{wide ? permissionLabel(p.code) : p.code}</span>
|
||||||
|
{!wide && p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
|
||||||
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
|
||||||
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
|
||||||
|
|
||||||
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
|
||||||
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
|
||||||
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
|
||||||
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
|
||||||
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
|
||||||
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
|
||||||
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
|
||||||
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
level: z.string().min(1, 'Level is required'),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface RoleSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
role: RoleResponse | null
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoleSheet({ open, onClose, role, onSuccess }: RoleSheetProps) {
|
|
||||||
const isEdit = !!role
|
|
||||||
|
|
||||||
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: { name: '', level: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
|
||||||
if (role) {
|
|
||||||
form.reset({ name: role.name, level: String(role.level) })
|
|
||||||
rolesApi.getPermissions(role.id)
|
|
||||||
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
|
||||||
.catch(() => {})
|
|
||||||
} else {
|
|
||||||
form.reset({ name: '', level: '' })
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
}
|
|
||||||
}, [open, role])
|
|
||||||
|
|
||||||
function togglePermission(id: string) {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
const body = { name: values.name, level: parseInt(values.level, 10) }
|
|
||||||
try {
|
|
||||||
let roleId: string
|
|
||||||
if (isEdit) {
|
|
||||||
await rolesApi.update(role!.id, body)
|
|
||||||
roleId = role!.id
|
|
||||||
toast.add({ title: 'Role updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
const created = await rolesApi.create(body)
|
|
||||||
roleId = created.id
|
|
||||||
toast.add({ title: 'Role created', type: 'success' })
|
|
||||||
}
|
|
||||||
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? 'Edit Role' : 'Add Role'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit ? 'Update role details and permissions.' : 'Create a new role and assign permissions.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Role Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Sales Rep"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="level"
|
|
||||||
label="Level"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select level" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROLE_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={String(l.value)}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{l.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{l.description}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{allPermissions.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Permissions</p>
|
|
||||||
<div className="border rounded-md max-h-[28rem] overflow-y-auto p-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{PERMISSION_CATEGORIES.map((cat) => {
|
|
||||||
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
|
||||||
if (catPerms.length === 0) return null
|
|
||||||
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
|
||||||
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`cat-${cat.id}`}
|
|
||||||
checked={allChecked}
|
|
||||||
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
|
||||||
{cat.label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 pl-6">
|
|
||||||
{catPerms.map((p) => (
|
|
||||||
<div key={p.permissionId} className="flex items-start gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={p.permissionId}
|
|
||||||
checked={selectedIds.has(p.permissionId)}
|
|
||||||
onCheckedChange={() => togglePermission(p.permissionId)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
|
||||||
<span className="font-medium">{p.code}</span>
|
|
||||||
{p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -12,17 +13,15 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
import type { RoleResponse } from '@/types/api'
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RoleSheet } from './role-sheet'
|
|
||||||
import { RoleViewSheet } from './role-view-sheet'
|
import { RoleViewSheet } from './role-view-sheet'
|
||||||
|
|
||||||
export function RolesClient() {
|
export function RolesClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<RoleResponse[]>([])
|
const [data, setData] = useState<RoleResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<RoleResponse | null>(null)
|
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
||||||
|
|
||||||
@@ -41,8 +40,8 @@ export function RolesClient() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
function openCreate() { setEditing(null); setSheetOpen(true) }
|
function openCreate() { router.push('/users/roles/new') }
|
||||||
function openEdit(role: RoleResponse) { setEditing(role); setSheetOpen(true) }
|
function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) }
|
||||||
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
||||||
|
|
||||||
async function handleDeactivate(role: RoleResponse) {
|
async function handleDeactivate(role: RoleResponse) {
|
||||||
@@ -122,13 +121,6 @@ export function RolesClient() {
|
|||||||
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
role={editing}
|
|
||||||
onSuccess={() => fetchData()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RoleViewSheet
|
<RoleViewSheet
|
||||||
open={viewOpen}
|
open={viewOpen}
|
||||||
onClose={() => setViewOpen(false)}
|
onClose={() => setViewOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import { RoleForm } from '../_components/role-form'
|
||||||
|
|
||||||
|
export default function NewRolePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Add Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Create a new role and assign permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<RoleForm role={null} onSuccess={goBack} onCancel={goBack} wide />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,11 +81,14 @@ export function AppSidebar() {
|
|||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<div className="flex items-center gap-2 px-2 py-1">
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
D
|
<img
|
||||||
</div>
|
src="https://hexdive.com/_next/image?url=%2F2.png&w=256&q=75"
|
||||||
|
alt="HexDive"
|
||||||
|
className="h-7 w-auto object-contain shrink-0"
|
||||||
|
/>
|
||||||
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||||
Distribution
|
HexaDistro
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
@@ -114,11 +117,15 @@ export function AppSidebar() {
|
|||||||
const visibleItems = group.items.filter((item) =>
|
const visibleItems = group.items.filter((item) =>
|
||||||
canSee(item, permissions),
|
canSee(item, permissions),
|
||||||
)
|
)
|
||||||
const groupActive = visibleItems.some(
|
const groupActive = visibleItems.some((item) => {
|
||||||
(item) =>
|
const hasSiblingUnder = visibleItems.some(
|
||||||
pathname === item.href ||
|
s => s.href !== item.href && s.href.startsWith(item.href + '/')
|
||||||
(item.href !== '/' && pathname.startsWith(item.href)),
|
|
||||||
)
|
)
|
||||||
|
return (
|
||||||
|
pathname === item.href ||
|
||||||
|
(!hasSiblingUnder && item.href !== '/' && pathname.startsWith(item.href + '/'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
@@ -150,10 +157,14 @@ export function AppSidebar() {
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
{visibleItems.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
|
const hasSiblingUnder = visibleItems.some(
|
||||||
|
s => s.href !== item.href && s.href.startsWith(item.href + '/')
|
||||||
|
)
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/' &&
|
(!hasSiblingUnder &&
|
||||||
pathname.startsWith(item.href))
|
item.href !== '/' &&
|
||||||
|
pathname.startsWith(item.href + '/'))
|
||||||
return (
|
return (
|
||||||
<Tooltip key={item.href}>
|
<Tooltip key={item.href}>
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { Sun, Moon } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{mounted && (resolvedTheme === 'dark' ? <Sun size={16} /> : <Moon size={16} />)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user