Compare commits

..

56 Commits

Author SHA1 Message Date
DeepnaPooja 6b608ad2ef Update permission feature with edit and create role functionality 2026-10-19 16:39:47 +05:30
DeepnaPooja 3c75f9786e Add multi-series charts and enhance dashboard functionality 2026-10-19 15:51:39 +05:30
DeepnaPooja 2d6723a040 Update dashboard 2026-10-19 15:48:45 +05:30
kalana_Thilakarathna 2fe2ba007c Merge pull request 'fix: update login function to handle profile data correctly' (#68) from dev into production
Reviewed-on: #68
2026-10-19 10:25:57 +05:30
Kalana B. Thilakarathna f3f2b71bd4 fix: update login function to handle profile data correctly
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m43s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m30s
2026-10-19 10:25:29 +05:30
kalana_Thilakarathna bc72426495 Merge pull request 'auth fix' (#67) from dev into production
Reviewed-on: #67
2026-10-19 10:20:29 +05:30
Kalana B. Thilakarathna da29492798 auth fix
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m10s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m51s
2026-10-19 10:19:40 +05:30
kalana_Thilakarathna ce21aa6588 Merge pull request 'feat: add toast notifications for successful and failed login attempts' (#66) from dev into production
Reviewed-on: #66
2026-10-19 02:50:47 +05:30
Kalana B. Thilakarathna f44cbf711b feat: add toast notifications for successful and failed login attempts
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m53s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m41s
2026-10-19 02:50:06 +05:30
kalana_Thilakarathna aaac42b481 Merge pull request 'Dev' (#65) from dev into production
Reviewed-on: #65
2026-10-19 02:45:35 +05:30
Kalana B. Thilakarathna 7a21cde217 refactor: simplify user API calls by removing pageSize parameter in multiple components
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m52s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-10-19 02:44:19 +05:30
Kalana B. Thilakarathna 531b3a7fef Redesign login UI, add theme toggle, refactor user update
- Redesigned login page with two-panel layout and branding
- Added HexaDistro logo and theme toggle to sidebar/header
- Refactored user update: roles via update endpoint, removed assignRole API/controller
- Extended UpdateUserRequest for username, role, password changes
- UserService.UpdateAsync syncs changes with AuthHex, with rollback
- Stock balances client dedupes by itemId-locationId
- Middleware matcher excludes static assets from auth checks
- Improved localStorage clearing for user context
- Added lucide-react for icons
- Minor UI/code cleanups for consistency and UX
2026-10-19 02:20:14 +05:30
kalana_Thilakarathna a81e32bbe2 Merge pull request 'fe fix for loggin redirection' (#64) from dev into production
Reviewed-on: #64
2026-10-19 02:16:31 +05:30
Kalana B. Thilakarathna 4ef63420b7 fe fix for loggin redirection
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m36s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m19s
2026-10-18 23:38:28 +05:30
Kalana B. Thilakarathna 9fdf5aa8e3 Merge branch 'dev' into production 2026-10-18 23:23:12 +05:30
Kalana B. Thilakarathna d9c339949e conflict fixed 2026-10-18 23:22:04 +05:30
DeepnaPooja 5d18aba1f9 Merge pull request 'Dev' (#63) from dev into production
Reviewed-on: #63
2026-10-16 10:53:09 +00:00
DeepnaPooja 5828879ddc Merge pull request 'chore: add exceljs, jspdf, and jspdf-autotable dependencies to package.json' (#62) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m0s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m11s
Reviewed-on: #62
2026-10-16 10:52:30 +00:00
DeepnaPooja d75a43d45f chore: add exceljs, jspdf, and jspdf-autotable dependencies to package.json 2026-10-16 16:16:43 +05:30
DeepnaPooja 728f2a8b17 Merge pull request 'Dev' (#61) from dev into production
Reviewed-on: #61
2026-10-16 10:26:15 +00:00
DeepnaPooja 1b66d5d767 Merge pull request 'Refactor/main flow' (#60) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m3s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m51s
Reviewed-on: #60
2026-10-16 10:25:08 +00:00
DeepnaPooja c5a0992b5d Fe changes 2026-10-16 15:47:23 +05:30
DeepnaPooja 09d86835a2 Implement Warehouse Dashboard features 2026-10-16 15:44:35 +05:30
DeepnaPooja d1d2d55352 Add stock report features r 2026-10-16 15:41:00 +05:30
DeepnaPooja c626f56cdb Refactor Return and Ride services to integrate StockMover and enhance error handling 2026-10-16 15:37:05 +05:30
DeepnaPooja 263e2ea9c0 Refactor Delivery and Purchase Return services to use StockMover for inventory movements 2026-10-16 15:34:25 +05:30
DeepnaPooja 5c37f02f19 Merge pull request 'Dev' (#59) from dev into production
Reviewed-on: #59
2026-10-09 04:38:19 +00:00
DeepnaPooja 6b3f28d3ae Merge pull request 'Refactor/main flow' (#58) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m0s
Reviewed-on: #58
2026-10-09 04:37:42 +00:00
DeepnaPooja e5f6aa15b9 Implement Store analytics and api integrations 2026-10-08 16:32:23 +05:30
DeepnaPooja 4e12b03e4b Add analytics functionality for stores 2026-10-08 16:28:56 +05:30
DeepnaPooja f5967497be Merge pull request 'Dev' (#57) from dev into production
Reviewed-on: #57
2026-10-08 07:36:24 +00:00
DeepnaPooja 4110e2fcfc Merge pull request 'Dev' (#55) from dev into production
Reviewed-on: #55
2026-10-07 05:17:54 +00:00
DeepnaPooja c52eaabec4 Merge pull request 'Dev' (#53) from dev into production
Reviewed-on: #53
2026-09-29 12:14:53 +00:00
DeepnaPooja 37f4a88014 Merge pull request 'Dev' (#48) from dev into production
Reviewed-on: #48
2026-09-28 07:39:55 +00:00
DeepnaPooja 2d365cd906 Merge pull request 'Dev' (#46) from dev into production
Reviewed-on: #46
2026-09-25 08:38:12 +00:00
DeepnaPooja c68c6e1ab9 Merge pull request 'Dev' (#44) from dev into production
Reviewed-on: #44
2026-09-24 12:00:43 +00:00
DeepnaPooja ddc3ecd3e6 Merge pull request 'Dev' (#42) from dev into production
Reviewed-on: #42
2026-09-23 08:38:21 +00:00
DeepnaPooja d7b6f29200 Merge pull request 'Dev' (#40) from dev into production
Reviewed-on: #40
2026-09-23 07:23:59 +00:00
DeepnaPooja d2277f626c Merge pull request 'Refactor/main flow' (#38) from refactor/main_flow into production
Reviewed-on: #38
2026-09-22 11:43:07 +00:00
kalana_Thilakarathna 3149902574 Merge pull request 'Dev' (#36) from dev into production
Reviewed-on: #36
2026-09-21 04:05:56 +00:00
kalana_Thilakarathna 37fb72376a Merge pull request 'Dev' (#35) from dev into production
Reviewed-on: #35
2026-09-21 03:47:22 +00:00
kalana_Thilakarathna 52a9a3fde7 Merge pull request 'Add build argument for NEXT_PUBLIC_API_URL in Dockerfile and CI/CD workflow' (#32) from dev into production
Reviewed-on: #32
2026-09-20 11:21:49 +00:00
kalana_Thilakarathna 1b310c86b3 Merge pull request 'Add Dockerfile and .dockerignore; configure Next.js output settings' (#31) from dev into production
Reviewed-on: #31
2026-09-20 11:07:29 +00:00
kalana_Thilakarathna e398ee9d6b Merge pull request 'docker log' (#30) from dev into production
Reviewed-on: #30
2026-09-20 11:03:16 +00:00
kalana_Thilakarathna a3c0211654 Merge pull request 'pipline update' (#29) from dev into production
Reviewed-on: #29
2026-09-20 10:53:45 +00:00
kalana_Thilakarathna e4e12aa05a Merge pull request 'build fix' (#28) from dev into production
Reviewed-on: #28
2026-09-20 10:42:24 +00:00
kalana_Thilakarathna 3103fb66ef Merge pull request 'Dev' (#27) from dev into production
Reviewed-on: #27
2026-09-20 10:35:40 +00:00
Kalana B. Thilakarathna 93355d34f1 Merge branch 'dev' into production 2026-09-16 11:30:37 +05:30
Kalana B. Thilakarathna 9151f47d15 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-16 10:58:02 +05:30
Kalana B. Thilakarathna 661b522b7d Merge branch 'dev' into production 2026-09-16 10:57:55 +05:30
DeepnaPooja e0bb17d0ff Merge pull request 'Dev' (#25) from dev into production
Reviewed-on: #25
2026-09-16 05:13:39 +00:00
Kalana B. Thilakarathna 0f29413375 Merge branch 'dev' into production 2026-09-16 10:41:32 +05:30
DeepnaPooja ef17f7da0a Merge pull request 'Dev' (#23) from dev into production
Reviewed-on: #23
2026-09-14 06:29:20 +00:00
DeepnaPooja d9031f8e73 Merge pull request 'Dev' (#20) from dev into production
Reviewed-on: #20
2026-09-01 10:28:44 +00:00
Kalana B. Thilakarathna 75578ea7f6 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-01 10:54:20 +05:30
DeepnaPooja 4f02fa303d Merge pull request 'Dev' (#18) from dev into production
Reviewed-on: #18
2026-08-31 07:41:03 +00:00
117 changed files with 8952 additions and 1094 deletions
@@ -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 Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -6,7 +7,7 @@ namespace Distribution.Features.DeliveryRide;
[ApiController]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
@@ -68,7 +69,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
{
try
{
var result = await service.ConfirmAsync(id, ct);
var result = await service.ConfirmAsync(id, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -12,7 +12,7 @@ public class DeliveryTaskService(
IRideRepository rideRepo,
IShopPORepository shopPORepo,
ILoadingSheetRepository loadingSheetRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
@@ -134,7 +134,7 @@ public class DeliveryTaskService(
return ToResponse(saved!);
}
public async Task<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)
?? throw new KeyNotFoundException("Delivery task not found");
@@ -166,29 +166,12 @@ public class DeliveryTaskService(
{
foreach (var item in task.ShopPO.Items.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
var availableQuantity = vehicleBalance?.Quantity ?? 0;
var itemName = item.Item?.Name ?? item.FK_ItemId.ToString();
if (availableQuantity < item.QuantityFulfilled)
throw new InvalidOperationException(
$"Insufficient vehicle stock for item '{itemName}': available {availableQuantity}, required {item.QuantityFulfilled}");
vehicleBalance!.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_ShopPOId
});
await mover.MoveAsync(item.FK_ItemId, itemName,
vehicleId, LocationType.Vehicle,
StockService.ExternalLocationId, LocationType.External,
item.QuantityFulfilled, MovementType.Sale, task.FK_ShopPOId, userId);
}
repo.Update(task);
@@ -8,7 +8,7 @@ namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
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)
{
// 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))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, item.FK_WarehouseId)
?? throw new InvalidOperationException(
$"No stock found for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}'");
var label = $"{item.Item?.Name ?? item.FK_ItemId.ToString()} (warehouse {item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()})";
if (warehouseBalance.Quantity < item.QuantityDispatched)
throw new InvalidOperationException(
$"Insufficient stock for '{item.Item?.Name ?? item.FK_ItemId.ToString()}' in warehouse '{item.Warehouse?.Name ?? item.FK_WarehouseId.ToString()}' " +
$"(available {warehouseBalance.Quantity}, needed {item.QuantityDispatched})");
warehouseBalance.Quantity -= item.QuantityDispatched;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = createdVehicleBalances.GetValueOrDefault(item.FK_ItemId)
?? await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId);
if (vehicleBalance is null)
{
var created = new StockBalance
{
FK_ItemId = item.FK_ItemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched
};
createdVehicleBalances[item.FK_ItemId] = created;
await stockRepo.AddBalanceAsync(created);
}
else
{
vehicleBalance.Quantity += item.QuantityDispatched;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = item.FK_WarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = item.QuantityDispatched,
Type = MovementType.Load,
ReferenceId = sheetId,
CreatedBy = userId
});
await mover.MoveAsync(item.FK_ItemId, label,
item.FK_WarehouseId, LocationType.Warehouse,
vehicleId, LocationType.Vehicle,
item.QuantityDispatched, MovementType.Load, sheetId, userId);
}
}
@@ -10,7 +10,7 @@ public class PurchaseReturnService(
IPurchaseReturnRepository repo,
IPoRepository poRepo,
IGrnRepository goodsReceiptRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
@@ -89,29 +89,12 @@ public class PurchaseReturnService(
foreach (var line in purchaseReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
var itemName = po.Items.FirstOrDefault(i => i.FK_ItemId == line.FK_ItemId)?.Item?.Name ?? line.FK_ItemId.ToString();
if (balance.Quantity < line.Quantity)
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
balance.Quantity -= line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
FromLocationId = warehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = StockService.ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = line.Quantity,
Type = MovementType.PurchaseReturn,
ReasonCode = request.Reason,
ReferenceId = purchaseReturn.Id,
CreatedBy = createdByUserId
});
await mover.MoveAsync(line.FK_ItemId, itemName,
warehouseId, LocationType.Warehouse,
StockService.ExternalLocationId, LocationType.External,
line.Quantity, MovementType.PurchaseReturn, purchaseReturn.Id, createdByUserId, request.Reason);
}
await uow.CommitAsync(ct);
@@ -48,7 +48,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
var result = await service.CreateAsync(id, request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (NotImplementedException ex) { return StatusCode(501, new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+71 -3
View File
@@ -1,3 +1,4 @@
using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
@@ -10,7 +11,8 @@ public class ReturnService(
IReturnRepository repo,
IShopPORepository shopPORepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
ICategoryRepository categoryRepo,
StockMover mover,
IUnitOfWork uow)
{
private static SaleReturnItemResponse MapItem(SaleReturnItem i) =>
@@ -57,6 +59,72 @@ public class ReturnService(
public async Task<List<SaleReturnResponse>> GetByShopPOIdAsync(Guid shopPOId) =>
(await repo.GetByShopPOIdAsync(shopPOId)).Select(Map).ToList();
public Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default) =>
throw new NotImplementedException("Return flow is being redesigned — not yet implemented.");
/// <summary>
/// Stock-only: records the return and puts the goods back on the vehicle that delivered them (restoring the
/// batches/serials that were sold). Credit/exchange money handling is not applied here, so TotalCreditAmount stays 0.
/// </summary>
public async Task<SaleReturnResponse> CreateAsync(Guid shopPOId, CreateSaleReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var shopPO = await shopPORepo.GetByIdAsync(shopPOId);
if (shopPO is null || !shopPO.IsActive)
throw new InvalidOperationException("Order not found.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A return must have at least one line item.");
if (request.Items.GroupBy(i => i.ItemId).Any(g => g.Count() > 1))
throw new InvalidOperationException("Each item can appear only once on a return.");
var saleReturn = new SaleReturn
{
FK_ShopPOId = shopPOId,
Mode = request.Mode,
TotalCreditAmount = 0,
CreatedByUserId = createdByUserId
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
var shopPOItem = shopPO.Items.FirstOrDefault(i => i.FK_ItemId == line.ItemId && i.IsActive)
?? throw new InvalidOperationException($"Item {line.ItemId} is not on this order.");
var item = await itemRepo.GetByIdAsync(line.ItemId)
?? throw new InvalidOperationException("Item not found.");
var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId)
?? throw new InvalidOperationException("Category not found.");
if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
saleReturn.Items.Add(new SaleReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
ReasonCode = line.ReasonCode,
UnitPrice = shopPOItem.UnitPrice
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(saleReturn);
foreach (var line in saleReturn.Items)
{
var itemName = shopPO.Items.First(i => i.FK_ItemId == line.FK_ItemId).Item?.Name ?? line.FK_ItemId.ToString();
await mover.ReturnToVehicleAsync(line.FK_ItemId, itemName, line.Quantity, shopPOId, createdByUserId, line.ReasonCode.ToString());
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(saleReturn.Id);
return Map(saved!);
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Ride;
[ApiController]
[Route("api/rides")]
public class RideController(RideService service) : ControllerBase
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll(
@@ -54,7 +55,7 @@ public class RideController(RideService service) : ControllerBase
{
try
{
var result = await service.CloseAsync(id, request, ct);
var result = await service.CloseAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -66,7 +67,7 @@ public class RideController(RideService service) : ControllerBase
{
try
{
var result = await service.CancelAsync(id, request, ct);
var result = await service.CancelAsync(id, request, currentUser.UserId ?? Guid.Empty, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
+16 -14
View File
@@ -10,7 +10,7 @@ public class RideService(
IRideRepository rideRepo,
IDriverProfileRepository driverRepo,
IDriverComplianceConfigRepository complianceRepo,
IStockRepository stockRepo,
StockMover mover,
IUnitOfWork uow)
{
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!);
}
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)
?? throw new KeyNotFoundException("Ride not found");
@@ -87,20 +87,24 @@ public class RideService(
if (request.Meterend < ride.Meterstart)
throw new InvalidOperationException($"Meter end ({request.Meterend}) cannot be less than meter start ({ride.Meterstart})");
await EnsureVehicleStockClearedAsync(ride.FK_VehicleId);
ride.Status = RideStatus.Closed;
ride.ClosedAt = DateTime.Now;
ride.Meterend = request.Meterend;
// Whatever wasn't delivered goes back to the warehouse it was loaded from.
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideClosed");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
}
public async Task<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)
?? throw new KeyNotFoundException("Ride not found");
@@ -113,7 +117,12 @@ public class RideService(
ride.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try { rideRepo.Update(ride); await uow.CommitAsync(ct); }
try
{
await mover.UnloadVehicleAsync(ride.FK_VehicleId, ride.Id, userId, "RideCancelled");
rideRepo.Update(ride);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(ride);
@@ -139,13 +148,6 @@ public class RideService(
throw new InvalidOperationException("Driver medical clearance has expired");
}
private async Task EnsureVehicleStockClearedAsync(Guid vehicleId)
{
var balances = await stockRepo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle);
if (balances.Any(b => b.Quantity > 0))
throw new InvalidOperationException("All vehicle stock must be returned before this ride can be completed");
}
private static RideResponse ToResponse(DeliveryRun r) =>
new(r.Id, r.Status,
r.FK_VehicleId, r.Vehicle?.NumberPlate ?? string.Empty,
@@ -7,6 +7,8 @@ public interface IStockRepository
// Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
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>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
@@ -19,14 +21,21 @@ public interface IStockRepository
Task AddMovementAsync(StockMovement movement);
Task<StockMovement?> GetMovementByIdAsync(Guid id);
void UpdateMovement(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
Task<List<StockMovement>> GetMovementsAsync(MovementFilter filter, int page, int pageSize);
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).
Task<ItemBatch?> FindBatchAsync(Guid itemId, string batchNumber);
Task AddItemBatchAsync(ItemBatch batch);
// Serial numbers
Task<ItemSerialNumber?> GetSerialAsync(Guid serialId);
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>
Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix);
+19 -1
View File
@@ -58,10 +58,28 @@ public class StockController(StockService service, ICurrentUserService currentUs
[FromQuery] MovementType? type,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] Guid? referenceId,
[FromQuery] Guid? batchId,
[FromQuery] Guid? serialId,
[FromQuery] Guid? createdBy,
[FromQuery] string? reasonCode,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var filter = new MovementFilter(locationId, itemId, type, from, to, referenceId, batchId, serialId, createdBy, reasonCode);
var result = await service.GetMovementsAsync(filter, page, pageSize);
return Ok(result);
}
/// <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);
}
+26 -1
View File
@@ -45,7 +45,12 @@ public record StockMovementResponse(
DateTime CreatedAt,
Guid CreatedBy,
string? FromLocationName,
string? ToLocationName);
string? ToLocationName,
Guid? BatchId = null,
string? BatchNumber = null,
DateTime? BatchExpiryDate = null,
Guid? SerialId = null,
string? SerialNumber = null);
// Write requests
public record TransferRequest(
@@ -82,3 +87,23 @@ public record AdjustmentRequest(
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
public record UpdateMovementReasonRequest(string ReasonCode);
// Movement report filters
public record MovementFilter(
Guid? LocationId = null,
Guid? ItemId = null,
MovementType? Type = null,
DateTime? From = null,
DateTime? To = null,
Guid? ReferenceId = null,
Guid? BatchId = null,
Guid? SerialId = null,
Guid? CreatedBy = null,
string? ReasonCode = null);
public record MovementSummary(
decimal OpeningQuantity,
decimal QuantityIn,
decimal QuantityOut,
decimal ClosingQuantity,
decimal CurrentBalance);
+234
View File
@@ -0,0 +1,234 @@
using Distribution.Features.SysConfig;
using Distribution.Models;
namespace Distribution.Features.Stock;
/// <summary>
/// Applies a stock movement end to end: picks which batch/serial balances to take from (FEFO / FIFO),
/// updates the source and destination balances, keeps serial status/location in step, and writes one
/// StockMovement per slice so the ledger always carries the batch/serial that actually moved.
/// Tracks changes only — the calling service owns the unit of work.
/// </summary>
public class StockMover(IStockRepository repo, SysConfigService sysConfig)
{
private static readonly Guid External = StockService.ExternalLocationId;
private readonly record struct StockKey(Guid LocationId, Guid? BatchId, Guid? SerialId);
public async Task<List<StockMovement>> MoveAsync(
Guid itemId, string itemName,
Guid fromId, LocationType fromType,
Guid toId, LocationType toType,
decimal quantity, MovementType type,
Guid? referenceId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var balances = await repo.GetAllocatableBalancesAsync(itemId, fromId);
var available = balances.Sum(b => b.Quantity);
if (available < quantity)
throw new InvalidOperationException(
$"Insufficient stock for '{itemName}': available {available}, required {quantity}");
var created = new List<StockMovement>();
var remaining = quantity;
foreach (var balance in balances)
{
if (remaining <= 0) break;
var take = Math.Min(balance.Quantity, remaining);
balance.Quantity -= take;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
if (toType != LocationType.External)
await CreditAsync(itemId, toId, toType, balance.FK_ItemBatchId, balance.FK_ItemSerialNumberId, take);
if (balance.ItemSerialNumber is not null)
await ApplySerialAsync(balance.ItemSerialNumber, balance.Item, toId, toType, type);
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = fromId,
FromLocationType = fromType,
ToLocationId = toId,
ToLocationType = toType,
FK_ItemBatchId = balance.FK_ItemBatchId,
FK_ItemSerialNumberId = balance.FK_ItemSerialNumberId,
Quantity = take,
Type = type,
ReasonCode = reasonCode,
ReferenceId = referenceId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
/// <summary>
/// Puts returned goods back on the vehicle that delivered them, restoring the same batches/serials that
/// were sold on that ShopPO. Quantity is capped at what was delivered and not yet returned.
/// </summary>
public async Task<List<StockMovement>> ReturnToVehicleAsync(
Guid itemId, string itemName, decimal quantity, Guid shopPoId, Guid userId, string? reasonCode = null)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be greater than zero.");
var sold = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.Sale);
if (sold.Count == 0)
throw new InvalidOperationException($"'{itemName}' was not delivered on this order, so it cannot be returned.");
var returned = await repo.GetMovementsByReferenceAsync(shopPoId, itemId, MovementType.CustomerReturn);
var outstanding = new Dictionary<StockKey, decimal>();
foreach (var m in sold)
{
var key = new StockKey(m.FromLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) + m.Quantity;
}
foreach (var m in returned)
{
var key = new StockKey(m.ToLocationId!.Value, m.FK_ItemBatchId, m.FK_ItemSerialNumberId);
outstanding[key] = outstanding.GetValueOrDefault(key) - m.Quantity;
}
var returnable = outstanding.Where(kv => kv.Value > 0).Sum(kv => kv.Value);
if (quantity > returnable)
throw new InvalidOperationException(
$"Return quantity for '{itemName}' ({quantity}) exceeds the delivered-but-not-yet-returned quantity ({returnable}).");
var created = new List<StockMovement>();
var remaining = quantity;
foreach (var (key, qty) in outstanding.Where(kv => kv.Value > 0))
{
if (remaining <= 0) break;
var take = Math.Min(qty, remaining);
await CreditAsync(itemId, key.LocationId, LocationType.Vehicle, key.BatchId, key.SerialId, take);
if (key.SerialId is Guid serialId)
{
var serial = await repo.GetSerialAsync(serialId);
if (serial is not null)
await ApplySerialAsync(serial, null, key.LocationId, LocationType.Vehicle, MovementType.CustomerReturn);
}
var movement = new StockMovement
{
FK_ItemId = itemId,
FromLocationId = External,
FromLocationType = LocationType.External,
ToLocationId = key.LocationId,
ToLocationType = LocationType.Vehicle,
FK_ItemBatchId = key.BatchId,
FK_ItemSerialNumberId = key.SerialId,
Quantity = take,
Type = MovementType.CustomerReturn,
ReasonCode = reasonCode,
ReferenceId = shopPoId,
CreatedBy = userId
};
await repo.AddMovementAsync(movement);
created.Add(movement);
remaining -= take;
}
return created;
}
/// <summary>
/// Sends everything still on the vehicle back to the warehouse it was loaded from.
/// </summary>
public async Task UnloadVehicleAsync(Guid vehicleId, Guid referenceId, Guid userId, string reasonCode)
{
var onVehicle = (await repo.GetBalancesByLocationAsync(vehicleId, LocationType.Vehicle))
.Where(b => b.Quantity > 0)
.GroupBy(b => b.FK_ItemId);
foreach (var group in onVehicle)
{
var item = group.First().Item;
var warehouseId = await repo.GetLastWarehouseSourceAsync(vehicleId, group.Key)
?? throw new InvalidOperationException(
$"Cannot unload '{item.Name}': no source warehouse found for the stock on this vehicle.");
await MoveAsync(group.Key, item.Name, vehicleId, LocationType.Vehicle,
warehouseId, LocationType.Warehouse, group.Sum(b => b.Quantity),
MovementType.Transfer, referenceId, userId, reasonCode);
}
}
private async Task CreditAsync(Guid itemId, Guid locationId, LocationType locationType,
Guid? batchId, Guid? serialId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId, batchId, serialId);
if (balance is null)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = locationId,
LocationType = locationType,
FK_ItemBatchId = batchId,
FK_ItemSerialNumberId = serialId,
Quantity = quantity
});
return;
}
balance.Quantity += quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
private async Task ApplySerialAsync(ItemSerialNumber serial, Models.Item? item, Guid toId, LocationType toType, MovementType type)
{
switch (type)
{
case MovementType.Sale:
serial.Status = SerialStatus.Sold;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
// Warranty clock starts at sale, not receipt.
if (item is { HasWarranty: true } && await sysConfig.GetWarrantyTrackingEnabledAsync())
{
var days = serial.WarrantyPeriodDaysOverride ?? await sysConfig.GetDefaultWarrantyPeriodDaysAsync();
serial.WarrantyExpiresAt = DateTime.Now.AddDays(days);
}
break;
case MovementType.Damage:
serial.Status = SerialStatus.Damaged;
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
break;
case MovementType.CustomerReturn:
serial.Status = SerialStatus.Returned;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
break;
default:
if (toType == LocationType.External)
{
serial.CurrentLocationId = null;
serial.CurrentLocationType = null;
}
else
{
if (type == MovementType.Load) serial.Status = SerialStatus.InStock;
serial.CurrentLocationId = toId;
serial.CurrentLocationType = toType;
}
break;
}
}
}
+97 -27
View File
@@ -7,16 +7,39 @@ namespace Distribution.Features.Stock;
public class StockRepository(AppDBContext db) : IStockRepository
{
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& b.FK_ItemBatchId == null && b.FK_ItemSerialNumberId == null);
GetBalanceAsync(itemId, locationId, null, null);
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId, Guid? itemBatchId, Guid? itemSerialNumberId) =>
db.StockBalances
// Looks at rows added earlier in the same unit of work first — they aren't in the database yet,
// so a second movement for the same (item, location, batch, serial) would otherwise insert a duplicate.
public async Task<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)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId
&& 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) =>
db.StockBalances
@@ -69,34 +92,78 @@ public class StockRepository(AppDBContext db) : IStockRepository
await db.StockMovements.AddAsync(movement);
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) =>
db.StockMovements.Update(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
private IQueryable<StockMovement> FilterMovements(MovementFilter f)
{
var query = db.StockMovements.AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.CountAsync();
if (f.LocationId.HasValue)
query = query.Where(m => m.FromLocationId == f.LocationId.Value || m.ToLocationId == f.LocationId.Value);
if (f.ItemId.HasValue) query = query.Where(m => m.FK_ItemId == f.ItemId.Value);
if (f.Type.HasValue) query = query.Where(m => m.Type == f.Type.Value);
if (f.ReferenceId.HasValue) query = query.Where(m => m.ReferenceId == f.ReferenceId.Value);
if (f.BatchId.HasValue) query = query.Where(m => m.FK_ItemBatchId == f.BatchId.Value);
if (f.SerialId.HasValue) query = query.Where(m => m.FK_ItemSerialNumberId == f.SerialId.Value);
if (f.CreatedBy.HasValue) query = query.Where(m => m.CreatedBy == f.CreatedBy.Value);
if (!string.IsNullOrWhiteSpace(f.ReasonCode)) query = query.Where(m => m.ReasonCode == f.ReasonCode);
if (f.From.HasValue) query = query.Where(m => m.CreatedAt >= f.From.Value);
if (f.To.HasValue)
{
// A bare date (midnight) means "through the end of that day".
var to = f.To.Value;
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
query = query.Where(m => m.CreatedAt < toExclusive);
}
return query;
}
public Task<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) =>
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) =>
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) =>
db.ItemSerialNumbers.AnyAsync(s => s.FK_ItemId == itemId && s.SerialNumber == serialNumber);
+92 -93
View File
@@ -13,6 +13,7 @@ public class StockService(
ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo,
StockMover mover,
IUnitOfWork uow)
{
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
@@ -37,7 +38,9 @@ public class StockService(
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName);
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName,
m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate,
m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber);
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{
@@ -112,11 +115,16 @@ public class StockService(
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
{
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive);
}
public async Task<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)>();
foreach (var m in items)
@@ -137,60 +145,42 @@ public class StockService(
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.FromLocationId,
FromLocationType = request.FromLocationType,
ToLocationId = request.ToLocationId,
ToLocationType = request.ToLocationType,
Quantity = request.Quantity,
Type = MovementType.Transfer,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity);
await repo.AddMovementAsync(movement);
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.FromLocationId, request.FromLocationType,
request.ToLocationId, request.ToLocationType,
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.LocationId,
FromLocationType = request.LocationType,
ToLocationId = ExternalLocationId,
ToLocationType = LocationType.External,
Quantity = request.Quantity,
Type = MovementType.Damage,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity);
await repo.AddMovementAsync(movement);
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
@@ -204,6 +194,22 @@ public class StockService(
if (!category.AllowCustomerReturn)
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
{
FK_ItemId = request.ItemId,
@@ -216,63 +222,61 @@ public class StockService(
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId);
if (balance is not null && balance.Quantity + request.Quantity < 0)
throw new InvalidOperationException("Adjustment would result in negative stock.");
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
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
{
FK_ItemId = request.ItemId,
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
Quantity = Math.Abs(request.Quantity),
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.LocationId,
ToLocationType = request.LocationType,
Quantity = request.Quantity,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
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 CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity));
}
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
@@ -282,28 +286,29 @@ public class StockService(
?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode;
await uow.BeginAsync(ct);
try
{
repo.UpdateMovement(movement);
await uow.SaveChangesAsync(ct);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
}
// ── Helpers ────────────────────────────────────────────────────────────
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity)
/// <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);
if (balance is null || balance.Quantity < quantity)
throw new InvalidOperationException("Insufficient stock at the specified location.");
}
private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId)
?? throw new InvalidOperationException("No stock balance found.");
balance.Quantity -= quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
var first = movements[0];
first.Item ??= item;
return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
}
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
@@ -326,10 +331,4 @@ public class StockService(
repo.UpdateBalance(balance);
}
}
private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity)
{
await DebitBalanceAsync(itemId, fromLocationId, quantity);
await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity);
}
}
@@ -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 Remove(Models.Store entity);
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)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
return StatusCode(403);
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
// return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
@@ -112,6 +112,13 @@ public class StoreController(
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")]
[Authorize("store.manage")]
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
+37
View File
@@ -59,3 +59,40 @@ public record SchemeEligibilityResponse(bool Eligible, string? Reason);
public record PatchStoreStatusRequest(bool IsActive);
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
.Where(o => o.FK_StoreId == storeId && o.IsActive && o.CreatedAt >= periodStart && o.CreatedAt < periodEndExclusive)
.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.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);
}
[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")]
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
{
+6 -1
View File
@@ -9,7 +9,12 @@ public record CreateUserRequest(
Guid RoleId,
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);
+44 -17
View File
@@ -44,7 +44,7 @@ public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient a
email = request.Username,
fullname = request.FullName,
password = request.Password,
roleId = request.RoleId,
roleId = request.RoleId.ToString(),
userTypeId = "00000001-0000-0000-0000-200000000000",
sendCredentialsEmail = false
}, 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);
if (user is null || !user.IsActive) return null;
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;
await uow.BeginAsync(ct);
try
{
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);
}
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)
{
var user = await repo.GetByIdAsync(id);
if (user is null || !user.IsActive) return null;
user.FK_RoleId = request.RoleId;
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);
return await UpdateAsync(id, new UpdateUserRequest(
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
RoleId: request.RoleId), ct);
}
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]
[Route("api/vehicles")]
public class VehicleController(VehicleService service) : ControllerBase
public class VehicleController(VehicleService service, VehicleDashboardService dashboardService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
@@ -17,6 +17,15 @@ public class VehicleController(VehicleService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
public async Task<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]
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]
[Route("api/warehouses")]
public class WarehouseController(WarehouseService service) : ControllerBase
public class WarehouseController(WarehouseService service, WarehouseDashboardService dashboardService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
@@ -17,6 +17,16 @@ public class WarehouseController(WarehouseService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id:guid}/dashboard")]
public async Task<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]
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",
};
}
}
+11
View File
@@ -30,8 +30,10 @@ using Distribution.Features.Sync;
using Distribution.Features.Uom;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.DashboardReport;
using Distribution.Features.ProductReport;
using Distribution.Features.Stock;
using Distribution.Features.StockReport;
using Distribution.Features.SysConfig;
using Distribution.Features.Warehouse;
using Distribution.Infra.Authorization;
@@ -54,8 +56,10 @@ builder.Services.AddScoped<IUnitOfWork, EFUnitOfWork>();
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IVehicleDashboardRepository, VehicleDashboardRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IWarehouseDashboardRepository, WarehouseDashboardRepository>();
builder.Services.AddScoped<IShopPORepository, ShopPORepository>();
builder.Services.AddScoped<IInvoiceRepository, InvoiceRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
@@ -68,6 +72,10 @@ builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
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<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
@@ -88,8 +96,10 @@ builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<VehicleDashboardService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<WarehouseDashboardService>();
builder.Services.AddScoped<ShopPOService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<UserService>();
@@ -101,6 +111,7 @@ builder.Services.AddScoped<UomService>();
builder.Services.AddScoped<ItemService>();
builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<StockService>();
builder.Services.AddScoped<StockMover>();
builder.Services.AddScoped<PriceListService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>();
+1 -1
View File
@@ -21,7 +21,7 @@
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:6705"
},
"StoreManagement": {
"UnverifiedKycOrderThreshold": 0
+140 -21
View File
@@ -1,12 +1,18 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { Space_Grotesk } from 'next/font/google'
import { useAuth } from '@/contexts/auth-context'
import { getApiError } from '@/lib/api'
import { toast } from '@/components/ui/toast'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
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() {
const { login, user, loading } = useAuth()
@@ -15,11 +21,10 @@ export default function LoginPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [showPassword, setShowPassword] = useState(false)
useEffect(() => {
if (!loading && user) {
router.replace('/')
}
if (!loading && user) router.replace('/')
}, [user, loading, router])
async function handleSubmit(e: FormEvent) {
@@ -28,55 +33,169 @@ export default function LoginPage() {
setSubmitting(true)
try {
await login(identifier, password)
toast.add({ title: 'Signed in successfully', type: 'success' })
} catch (err) {
setError(getApiError(err))
const message = getApiError(err)
setError(message)
toast.add({ title: 'Sign in failed', description: message, type: 'error' })
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-svh items-center justify-center bg-background">
<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">
<h1 className="text-2xl font-semibold text-foreground">Sign in</h1>
<p className="text-sm text-muted-foreground">Distribution ERP</p>
<div className={`${sg.className} min-h-svh flex flex-col md:flex-row`}>
{/* ── Left panel: image (hidden on mobile, 60% on desktop) ── */}
<div className="relative hidden md:flex md:w-[60%] shrink-0 flex-col">
<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 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">
<Label htmlFor="identifier">Email</Label>
<Label htmlFor="identifier" className="text-gray-700">
Username or Email
</Label>
<Input
id="identifier"
type="email"
autoComplete="email"
type="text"
autoComplete="username"
required
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 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
id="password"
type="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
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>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? 'Signing in…' : 'Sign in'}
<Button type="submit" disabled={submitting} className="w-full mt-2">
{submitting ? 'Signing in…' : 'Sign In'}
</Button>
</div>
</form>
</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'
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 type { Layout, LayoutItem } from 'react-grid-layout'
import type { LayoutItem } from 'react-grid-layout'
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 { toast } from '@/components/ui/toast'
import {
@@ -23,16 +19,18 @@ import {
} from '@/lib/api/index'
import { useAuth } from '@/contexts/auth-context'
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 = 36
const GRID_MARGIN: [number, number] = [16, 16]
const ROW_HEIGHT = 26
const GRID_MARGIN: [number, number] = [10, 10]
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
type Category = 'sales' | 'ops' | 'stock' | 'finance'
type Size = 'sm' | 'md' | 'lg'
type Size = 'sm' | 'md' | 'lg' | 'chart' | 'chartWide'
interface WidgetMeta {
id: string
@@ -66,6 +64,7 @@ const WIDGET_META: WidgetMeta[] = [
const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
]
/** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints
@@ -73,28 +72,38 @@ const DEFAULT_ACTIVE = [
const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
lg: {
sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 5, minW: 3, minH: 3 },
md: { w: 6, h: 6, minW: 3, minH: 3 },
lg: { w: 12, h: 7, minW: 4, minH: 4 },
chart: { w: 4, h: 7, minW: 3, minH: 5 },
chartWide: { w: 8, h: 7, minW: 4, minH: 5 },
},
md: {
sm: { w: 5, h: 4, minW: 3, minH: 3 },
md: { w: 10, h: 5, minW: 4, minH: 3 },
md: { w: 5, h: 6, minW: 3, minH: 3 },
lg: { w: 10, h: 7, minW: 4, minH: 4 },
chart: { w: 5, h: 7, minW: 3, minH: 5 },
chartWide: { w: 10, h: 7, minW: 4, minH: 5 },
},
sm: {
sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 5, minW: 3, minH: 3 },
md: { w: 3, h: 6, minW: 2, minH: 3 },
lg: { w: 6, h: 7, minW: 3, minH: 4 },
chart: { w: 6, h: 7, minW: 3, minH: 5 },
chartWide: { w: 6, h: 7, minW: 3, minH: 5 },
},
xs: {
sm: { w: 4, h: 4, minW: 2, minH: 3 },
md: { w: 4, h: 5, minW: 2, minH: 3 },
md: { w: 4, h: 6, minW: 2, minH: 3 },
lg: { w: 4, h: 7, minW: 2, minH: 4 },
chart: { w: 4, h: 7, minW: 2, minH: 5 },
chartWide: { w: 4, h: 7, minW: 2, minH: 5 },
},
xxs: {
sm: { w: 2, h: 4, minW: 1, minH: 3 },
md: { w: 2, h: 5, minW: 1, minH: 3 },
md: { w: 2, h: 6, minW: 1, minH: 3 },
lg: { w: 2, h: 7, minW: 1, minH: 4 },
chart: { w: 2, h: 7, minW: 1, minH: 5 },
chartWide: { w: 2, h: 7, minW: 1, minH: 5 },
},
}
@@ -120,54 +129,6 @@ function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
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 })}`
interface OpenRide {
@@ -200,6 +161,10 @@ interface DashboardData {
draftInvoiceCount: number
printedInvoiceCount: number
finalizedInvoiceCount: number
poStatusChart: ChartDatum[]
paymentStatusChart: ChartDatum[]
movementTypeChart: ChartDatum[]
monthlySales: MonthlySalesPoint[]
}
function emptyDashboardData(): DashboardData {
@@ -211,6 +176,7 @@ function emptyDashboardData(): DashboardData {
lowStock: [], movements: [], warehouseBalances: [],
outstandingAmount: 0, outstandingInvoiceCount: 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
}
function CategoryPill({ cat }: { cat: Category }) {
const meta = CATEGORY_META[cat]
const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus']
function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) {
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
{meta.label}
</span>
<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>
<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 (
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{STAT_IDS.filter(id => ids.includes(id)).map(id => <div key={id}>{tiles[id]}</div>)}
</div>
)
}
@@ -355,26 +352,20 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
case 'pendingPO':
return (
<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>
)
case 'awaitingConfirm':
return (
<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>
)
case 'fulfillRate': {
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
return (
<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>
)
return <RadialGauge pct={pct} label="Fulfilled" sub={`${data.fulfilledPOCount} of ${data.totalPOCount} orders fulfilled`} />
}
case 'openRides':
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-xs text-muted-foreground">{r.driverFullName}</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>
)
case 'loadingSheetsByStatus': {
const total = data.plannedSheetsCount + data.confirmedSheetsCount
const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0
const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0
case 'loadingSheetsByStatus':
return (
<div className="flex flex-col gap-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Planned</span>
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span>
</div>
<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>
<HorizontalBarChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
emptyText="No loading sheets yet."
/>
)
}
case 'deliveryTasks':
return (
<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>
)
@@ -458,88 +436,90 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))}
</div>
)
case 'warehouseBalance': {
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity))
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 'warehouseBalance':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'outstandingPay':
return (
<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>
)
case 'invoiceStatus': {
const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
const segs = [
{ label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' },
{ label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' },
{ label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' },
]
case 'invoiceStatus':
return (
<div>
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{segs.map(s => (
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} />
))}
</div>
<div className="mt-3.5 flex flex-col gap-1.5">
{segs.map(s => (
<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>
<DonutChart
data={[
{ name: 'Draft', value: data.draftInvoiceCount },
{ name: 'Printed', value: data.printedInvoiceCount },
{ name: 'Finalized', value: data.finalizedInvoiceCount },
]}
centerLabel="invoices"
emptyText="No invoices yet."
/>
)
}
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:
return null
}
}
function mergeLayoutsChange(
full: Record<Breakpoint, LayoutItem[]>,
changed: Partial<Record<Breakpoint, Layout>>,
): Record<Breakpoint, LayoutItem[]> {
const next = { ...full }
for (const bp of BREAKPOINT_ORDER) {
const changedArr = changed[bp]
if (!changedArr) continue
next[bp] = full[bp].map(item => {
const updated = changedArr.find(c => c.i === item.i)
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
})
}
return next
function toIsoDate(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom'
const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [
{ key: '7d', label: 'Last 7 days' },
{ key: '1m', label: '1 month' },
{ key: '3m', label: '3 months' },
{ key: '1y', label: '1 year' },
{ key: 'custom', label: 'Custom' },
]
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() {
const { hasPermission } = useAuth()
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
const [editMode, setEditMode] = useState(false)
const [showPicker, setShowPicker] = useState(false)
const [category, setCategory] = useState<'all' | Category>('all')
const [range, setRange] = useState<DateRange>(() => presetRange('1m'))
const [preset, setPreset] = useState<RangePreset>('1m')
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
const allowedIds = useMemo(
@@ -548,23 +528,10 @@ export function DashboardClient() {
)
const permittedActiveIds = useMemo(
() => state.activeIds.filter(id => allowedIds.has(id)),
[state.activeIds, allowedIds],
() => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)),
[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 () => {
setLoading(true)
try {
@@ -580,118 +547,82 @@ export function DashboardClient() {
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(
() => permittedActiveIds
.map(id => WIDGET_META.find(w => w.id === id))
.filter((w): w is WidgetMeta => Boolean(w))
.filter(w => category === 'all' || w.cat === category),
[permittedActiveIds, category],
.filter(w => !STAT_IDS.includes(w.id)),
[permittedActiveIds],
)
const visibleLayouts = useMemo(() => {
const visibleIds = new Set(visibleWidgets.map(w => w.id))
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[]>
}, [state.layouts, visibleWidgets])
}, [visibleWidgets])
const filters: Array<{ key: 'all' | Category; label: string }> = [
{ key: 'all', label: 'All widgets' },
{ 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 },
]
const canSales = hasPermission('orders.view')
const canInventory = hasPermission('stock.view')
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>
<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">
{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>
</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 className="flex flex-wrap gap-2">
{filters.map(f => (
{(canSales || canInventory) && (
<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
key={f.key}
onClick={() => setCategory(f.key)}
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
category === f.key
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
key={p.key}
onClick={() => { setPreset(p.key); if (p.key !== 'custom') setRange(presetRange(p.key)) }}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
preset === p.key ? 'bg-primary text-primary-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
}`}
>
{f.label}
{p.label}
</button>
))}
</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 ? (
<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" />
))}
</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 &ldquo;Widgets&rdquo; to add some.
</div>
) : (
<>
<StatStrip ids={permittedActiveIds} data={data} />
<div ref={containerRef} className="w-full">
{mounted && (
<ResponsiveGridLayout
@@ -715,32 +644,28 @@ export function DashboardClient() {
margin={GRID_MARGIN}
containerPadding={[0, 0]}
maxRows={Infinity}
dragConfig={{ enabled: editMode }}
resizeConfig={{ enabled: editMode, handles: ['se'] }}
dragConfig={{ enabled: false }}
resizeConfig={{ enabled: false }}
autoSize
onLayoutChange={handleLayoutChange}
>
{visibleWidgets.map(w => (
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
{editMode && (
<button
onClick={() => toggleWidget(w.id, false)}
aria-label="Remove widget"
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 key={w.id} className="relative flex flex-col overflow-auto rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md">
<div className="mb-2 shrink-0">
<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" />
{w.title}
</div>
</div>
<div className="min-h-0 flex-1">
<WidgetBody id={w.id} data={data} />
</div>
</div>
))}
</ResponsiveGridLayout>
)}
</div>
</>
)}
</div>
)
@@ -60,7 +60,7 @@ export function DriverSheet({ open, onClose, driver, onSuccess }: DriverSheetPro
useEffect(() => {
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' })
)
@@ -1,6 +1,7 @@
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/layout/app-sidebar'
import { BottomNav } from '@/components/layout/bottom-nav'
import { ThemeToggle } from '@/components/layout/theme-toggle'
export default function DashboardLayout({
children,
@@ -13,6 +14,9 @@ export default function DashboardLayout({
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
<SidebarTrigger className="-ml-1 hidden md:flex" />
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-20 md:pb-4">
{children}
@@ -89,11 +89,11 @@ export function RegionManageEntrySheet({ open, onClose, onSuccess, initial }: Re
Promise.all([
regionsApi.list({ page: 1, pageSize: 200 }),
usersApi.list({ page: 1, pageSize: 200 }),
usersApi.list(),
])
.then(([r, u]) => {
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' }))
}, [open, initial])
@@ -81,8 +81,8 @@ export function RegionManageListClient() {
}, [fetchData])
useEffect(() => {
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setAgencyUsers(getPaginatedItems(res)))
usersApi.list()
.then((res) => setAgencyUsers(res))
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
}, [])
@@ -45,13 +45,13 @@ export function RegionManagerDialog({ open, onClose, region }: RegionManagerDial
setCurrent(null)
Promise.all([
regionsApi.getManager(region.id).catch(() => null), // 404 = no manager yet
usersApi.list({ pageSize: 500 }),
usersApi.list(),
])
.then(([manager, userRes]) => {
if (cancelled) return
setCurrent(manager)
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' }))
.finally(() => !cancelled && setLoading(false))
@@ -46,8 +46,8 @@ export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManag
setOwnerUserId(route.ownerUserId ?? '')
setStoreFilter('')
usersApi.list({ page: 1, pageSize: 200 })
.then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep')))
usersApi.list()
.then((res) => setSalesReps(res.filter((u) => u.roleName === 'SalesRep')))
.catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' }))
storesApi.list({ page: 1, pageSize: 200 })
@@ -46,10 +46,10 @@ export function RoutesClient() {
}, [fetchData, page, pageSize])
useEffect(() => {
usersApi.list({ page: 1, pageSize: 200 })
usersApi.list()
.then((res) => {
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)
})
.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>
)
}
@@ -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'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -15,6 +16,7 @@ import type { VehicleResponse } from '@/types/api'
import { VehicleSheet } from './vehicle-sheet'
export function VehiclesClient() {
const router = useRouter()
const [data, setData] = useState<VehicleResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -118,6 +120,11 @@ export function VehiclesClient() {
],
rowActions: {
actions: (row) => [
{
action: 'view',
label: 'View dashboard',
onClick: (vehicle: VehicleResponse) => router.push(`/network/vehicles/${vehicle.id}`),
},
{
action: 'edit',
label: 'Edit vehicle',
@@ -131,7 +138,7 @@ export function VehiclesClient() {
},
],
},
}), [pageSize, openEdit, deactivate])
}), [pageSize, openEdit, deactivate, router])
return (
<>
@@ -0,0 +1,5 @@
import { WarehouseDashboard } from '../_components/dashboard/warehouse-dashboard'
export default function WarehouseDashboardPage() {
return <WarehouseDashboard />
}
@@ -0,0 +1,217 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { WarehouseDashboardResponse } from '@/types/api'
import { daysAgo, fmtDate, fmtMoney, fmtNum } from './format'
import { EmptyNote, Metric, Section } from './shared'
export function ActivitySection({ data }: { data: WarehouseDashboardResponse }) {
const { inbound, outbound, vehicles, losses, topMovers, deadStock } = data
const maxMover = Math.max(...topMovers.map(m => m.unitsOut), 1)
return (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Section
title="Inbound · Procurement"
description="Goods receipts (GRN) received into this warehouse"
href="/procurement/grn"
hrefLabel="All GRNs"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="GRNs" value={String(inbound.grnCount)} />
<Metric label="Accepted" value={fmtNum(inbound.acceptedUnits)} sub="units" />
<Metric label="Rejected" value={fmtNum(inbound.rejectedUnits)} sub="units" tone={inbound.rejectedUnits > 0 ? 'warning' : undefined} />
<Metric
label="Rejection rate"
value={`${inbound.rejectionRatePercent}%`}
tone={inbound.rejectionRatePercent > 5 ? 'danger' : undefined}
/>
</div>
{inbound.recentGrns.length === 0 ? (
<EmptyNote>No goods received in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Received</TableHead>
<TableHead>Supplier</TableHead>
<TableHead className="text-right">Accepted</TableHead>
<TableHead className="text-right">Rejected</TableHead>
<TableHead className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inbound.recentGrns.map(g => (
<TableRow key={g.id}>
<TableCell className="whitespace-nowrap">{fmtDate(g.receivedAt)}</TableCell>
<TableCell>{g.supplierName ?? <span className="text-muted-foreground">Direct receipt</span>}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.acceptedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(g.rejectedQuantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(g.costValue)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Outbound · Loading sheets"
description="Stock dispatched from this warehouse onto delivery vehicles"
href="/operations/loading"
hrefLabel="All loading sheets"
>
<div className="mb-3 grid grid-cols-2 gap-3">
<Metric label="Dispatched" value={fmtNum(outbound.unitsDispatched)} sub="units this period" />
<Metric label="Pending dispatch" value={fmtNum(outbound.pendingDispatchUnits)} sub="on planned sheets" />
</div>
{outbound.recentLoadingSheets.length === 0 ? (
<EmptyNote>No loading sheets in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Prepared</TableHead>
<TableHead>Vehicle</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Units</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{outbound.recentLoadingSheets.map(s => (
<TableRow key={s.id}>
<TableCell className="whitespace-nowrap">{fmtDate(s.populatedAt)}</TableCell>
<TableCell>{s.vehiclePlate}</TableCell>
<TableCell>
<Badge variant={s.status === 'Confirmed' ? 'default' : 'secondary'}>{s.status}</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{s.lines}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(s.units)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Vehicles" description="Stock currently on vehicles linked to or loaded from this warehouse">
{vehicles.length === 0 ? (
<EmptyNote>No vehicles are linked to this warehouse.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Vehicle</TableHead>
<TableHead className="text-right">Units on board</TableHead>
<TableHead className="text-right">SKUs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vehicles.map(v => (
<TableRow key={v.vehicleId}>
<TableCell>
<span className="font-medium">{v.numberPlate}</span>
<span className="ml-2 text-xs text-muted-foreground">{v.regNumber}</span>
{v.isLinked && <Badge variant="outline" className="ml-2">Linked</Badge>}
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(v.units)}</TableCell>
<TableCell className="text-right tabular-nums">{v.skus}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section
title="Returns & losses"
description="Stock leaving without a sale, plus open supplier claims"
href="/returns-claims/supplier-claims"
hrefLabel="Supplier claims"
>
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label="Purchase returns" value={fmtNum(losses.purchaseReturnUnits)} sub="units" />
<Metric label="Damaged" value={fmtNum(losses.damageUnits)} sub="units" tone={losses.damageUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted down" value={fmtNum(losses.negativeAdjustmentUnits)} sub="units" tone={losses.negativeAdjustmentUnits > 0 ? 'warning' : undefined} />
<Metric label="Adjusted up" value={fmtNum(losses.positiveAdjustmentUnits)} sub="units" />
</div>
{losses.openClaims.length === 0 ? (
<EmptyNote>No open supplier claims for goods received here.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{losses.openClaims.map(c => (
<li key={c.id} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{c.supplierName ?? 'Supplier'}</p>
<p className="truncate text-xs text-muted-foreground">{c.details}</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{fmtNum(c.units)} units · {fmtDate(c.createdAt)}</span>
</li>
))}
</ul>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Top movers" description="Most units loaded onto vehicles in this period">
{topMovers.length === 0 ? (
<EmptyNote>Nothing was dispatched in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{topMovers.map(m => (
<li key={m.itemId} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate font-medium">{m.name} <span className="text-xs font-normal text-muted-foreground">{m.sku}</span></span>
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtNum(m.unitsOut)} units</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${(m.unitsOut / maxMover) * 100}%`, background: 'var(--chart-2)' }} />
</div>
</li>
))}
</ul>
)}
</Section>
<Section title="Dead stock" description={`In stock but not sent out for ${data.deadStockDays}+ days`}>
{deadStock.length === 0 ? (
<EmptyNote>No idle stock found.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead>Last sent out</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deadStock.map(d => (
<TableRow key={d.itemId}>
<TableCell>
<p className="font-medium">{d.name}</p>
<p className="text-xs text-muted-foreground">{d.sku}</p>
</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(d.quantity)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(d.costValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{d.lastOutboundAt ? daysAgo(d.lastOutboundAt) : 'Never'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
</div>
)
}
@@ -0,0 +1,56 @@
import type { MovementType, WarehouseType } from '@/types/api'
export const fmtMoney = (v: number) =>
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
export const fmtNum = (v: number) =>
v.toLocaleString('en-LK', { maximumFractionDigits: 2 })
export const fmtCompact = (v: number) =>
v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
export const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
export const fmtDateTime = (v: string) =>
new Date(v).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
export const fmtShortDay = (v: string) =>
new Date(`${v}T00:00:00`).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
/** yyyy-MM-dd in local time, the format the dashboard endpoint and date inputs use. */
export function toIsoDate(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export function daysFromToday(offset: number) {
const d = new Date()
d.setDate(d.getDate() + offset)
return toIsoDate(d)
}
export const daysAgo = (v: string) => {
const d = Math.floor((Date.now() - new Date(v).getTime()) / 86_400_000)
return d <= 0 ? 'Today' : d === 1 ? '1 day ago' : `${d} days ago`
}
export const MOVEMENT_LABEL: Record<MovementType, string> = {
Sale: 'Sale',
CustomerReturn: 'Customer return',
Transfer: 'Transfer',
Damage: 'Damage write-off',
Adjustment: 'Adjustment',
Load: 'Loaded to vehicle',
PurchaseReceipt: 'Purchase receipt (GRN)',
PurchaseReturn: 'Purchase return',
}
export const WAREHOUSE_TYPE_LABEL: Record<WarehouseType, string> = {
DistributionCenter: 'Distribution Center',
FulfillmentCenter: 'Fulfillment Center',
CrossDocking: 'Cross Docking',
ColdStorage: 'Cold Storage',
BondedWarehouse: 'Bonded Warehouse',
AutomatedWarehouse: 'Automated Warehouse',
}
@@ -0,0 +1,153 @@
'use client'
import { useMemo, useState } from 'react'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { WarehouseDashboardResponse, WarehouseDashboardMovementTypeRow, WarehouseMovementDirection } from '@/types/api'
import { MOVEMENT_LABEL, fmtCompact, fmtDateTime, fmtNum, fmtShortDay } from './format'
import { EmptyNote, Section } from './shared'
const flowConfig = {
in: { label: 'Units in', color: 'var(--chart-2)' },
out: { label: 'Units out', color: 'var(--chart-1)' },
} satisfies ChartConfig
type DirectionFilter = 'all' | WarehouseMovementDirection
function TypeBreakdown({ title, rows }: { title: string; rows: WarehouseDashboardMovementTypeRow[] }) {
const total = rows.reduce((sum, r) => sum + r.units, 0)
return (
<Section title={title}>
{rows.length === 0 ? (
<EmptyNote>No movements in this period.</EmptyNote>
) : (
<ul className="flex flex-col gap-3">
{rows.map(r => (
<li key={r.type} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<span>{MOVEMENT_LABEL[r.type]}</span>
<span className="tabular-nums text-muted-foreground">
{fmtNum(r.units)} units · {r.count} {r.count === 1 ? 'entry' : 'entries'}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${total > 0 ? (r.units / total) * 100 : 0}%`,
background: r.direction === 'In' ? 'var(--chart-2)' : 'var(--chart-1)',
}}
/>
</div>
</li>
))}
</ul>
)}
</Section>
)
}
export function MovementSection({ data }: { data: WarehouseDashboardResponse }) {
const [direction, setDirection] = useState<DirectionFilter>('all')
const inbound = useMemo(() => data.movementByType.filter(r => r.direction === 'In'), [data.movementByType])
const outbound = useMemo(() => data.movementByType.filter(r => r.direction === 'Out'), [data.movementByType])
const totalEntries = data.movementByType.reduce((sum, r) => sum + r.count, 0)
const ledger = direction === 'all' ? data.ledger : data.ledger.filter(r => r.direction === direction)
return (
<div className="flex flex-col gap-4">
<Section title="Stock movement" description="Units received into and sent out of this warehouse, per day">
<ChartContainer config={flowConfig} className="h-64 w-full">
<BarChart data={data.dailyMovement}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={24} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={v => fmtShortDay(String(v))} />} />
<Bar dataKey="in" fill="var(--color-in)" radius={[4, 4, 0, 0]} />
<Bar dataKey="out" fill="var(--color-out)" radius={[4, 4, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
</Section>
<div className="grid gap-4 lg:grid-cols-2">
<TypeBreakdown title="Where stock comes from (In)" rows={inbound} />
<TypeBreakdown title="Where stock goes (Out)" rows={outbound} />
</div>
<Section
title="Movement ledger"
description={`Latest ${data.ledger.length} of ${totalEntries} movements in this period`}
href="/stock/movements"
hrefLabel="Full movement history"
>
<div className="mb-3 flex gap-1">
{(['all', 'In', 'Out'] as const).map(d => (
<Button key={d} size="sm" variant={direction === d ? 'default' : 'outline'} onClick={() => setDirection(d)}>
{d === 'all' ? 'All' : d}
</Button>
))}
</div>
{ledger.length === 0 ? (
<EmptyNote>No movements to show.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead>From → To</TableHead>
<TableHead>Reference</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledger.map(r => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">{fmtDateTime(r.createdAt)}</TableCell>
<TableCell>
<p className="font-medium">{r.itemName}</p>
<p className="text-xs text-muted-foreground">{r.sku}</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={r.direction === 'In' ? 'secondary' : 'outline'}>{r.direction}</Badge>
<span className="text-sm">{MOVEMENT_LABEL[r.type]}</span>
</div>
{r.reasonCode && <p className="mt-0.5 text-xs text-muted-foreground">{r.reasonCode}</p>}
</TableCell>
<TableCell className="text-right tabular-nums">
{r.direction === 'In' ? '+' : '−'}{fmtNum(r.quantity)}
</TableCell>
<TableCell className="whitespace-nowrap">{r.fromName} → {r.toName}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.referenceKind ? `${r.referenceKind} · ${r.referenceId?.slice(0, 8)}` : '—'}
</TableCell>
<TableCell className="whitespace-nowrap">{r.createdByName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</Section>
</div>
)
}
@@ -0,0 +1,62 @@
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function Metric({
label,
value,
sub,
tone,
}: {
label: string
value: string
sub?: string
tone?: 'danger' | 'warning'
}) {
const valueClass =
tone === 'danger' ? 'text-destructive' : tone === 'warning' ? 'text-amber-600 dark:text-amber-400' : ''
return (
<div className="flex flex-col gap-0.5 rounded-xl border px-3 py-2">
<span className="truncate text-[11px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={`truncate text-base font-semibold leading-tight ${valueClass}`}>{value}</span>
{sub && <span className="truncate text-[11px] text-muted-foreground">{sub}</span>}
</div>
)
}
export function Section({
title,
description,
href,
hrefLabel,
children,
}: {
title: string
description?: string
href?: string
hrefLabel?: string
children: React.ReactNode
}) {
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
{href && (
<Link href={href} className="shrink-0 text-xs font-medium text-primary hover:underline">
{hrefLabel ?? 'View all'}
</Link>
)}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
export function EmptyNote({ children }: { children: React.ReactNode }) {
return <p className="py-4 text-center text-sm text-muted-foreground">{children}</p>
}
@@ -0,0 +1,226 @@
'use client'
import { Fragment, useMemo, useState } from 'react'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { StockAdjustDialog } from '@/app/(dashboard)/stock/_components/stock-adjust-dialog'
import type {
StockBalanceResponse,
WarehouseDashboardResponse,
WarehouseDashboardStockRow,
WarehouseStockStatus,
} from '@/types/api'
import { daysAgo, fmtCompact, fmtDate, fmtMoney, fmtNum } from './format'
import { EmptyNote, Section } from './shared'
const PAGE_SIZE = 25
const ALL = 'all'
const categoryConfig = { value: { label: 'Stock value', color: 'var(--chart-2)' } } satisfies ChartConfig
const STATUS_BADGE: Record<WarehouseStockStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
OK: { label: 'In stock', variant: 'secondary' },
Low: { label: 'Low', variant: 'destructive' },
Out: { label: 'Out of stock', variant: 'destructive' },
}
export function StockSection({
data,
onChanged,
}: {
data: WarehouseDashboardResponse
onChanged: () => void
}) {
const [search, setSearch] = useState('')
const [category, setCategory] = useState(ALL)
const [status, setStatus] = useState(ALL)
const [visible, setVisible] = useState(PAGE_SIZE)
const [expanded, setExpanded] = useState<string | null>(null)
const [adjusting, setAdjusting] = useState<StockBalanceResponse | null>(null)
const categories = useMemo(() => [...new Set(data.stock.map(r => r.category))].sort(), [data.stock])
const rows = useMemo(() => {
const q = search.trim().toLowerCase()
return data.stock.filter(r =>
(category === ALL || r.category === category) &&
(status === ALL || r.status === status) &&
(!q || r.name.toLowerCase().includes(q) || r.sku.toLowerCase().includes(q)),
)
}, [data.stock, search, category, status])
function openAdjust(row: WarehouseDashboardStockRow) {
setAdjusting({
itemId: row.itemId,
itemName: row.name,
locationId: data.warehouse.id,
locationType: 'Warehouse',
quantity: row.quantity,
updatedAt: row.updatedAt,
locationName: data.warehouse.name,
})
}
return (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Section title="Stock value by category" description="At cost price, items currently on hand">
{data.stockByCategory.length === 0 ? (
<EmptyNote>No stock on hand.</EmptyNote>
) : (
<ChartContainer config={categoryConfig} className="w-full" style={{ height: Math.max(140, data.stockByCategory.length * 36) }}>
<BarChart data={data.stockByCategory} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="category" tickLine={false} axisLine={false} width={110} />
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={(v) => fmtMoney(Number(v))} />} />
<Bar dataKey="value" fill="var(--color-value)" radius={6} />
</BarChart>
</ChartContainer>
)}
</Section>
</div>
<Section title="Expiry watch" description="Batches expired or expiring within 30 days">
{data.expiringBatches.length === 0 ? (
<EmptyNote>No batches at risk.</EmptyNote>
) : (
<ul className="flex max-h-56 flex-col gap-2 overflow-y-auto">
{data.expiringBatches.map(b => (
<li key={`${b.itemId}-${b.batchNumber}-${b.expiryDate}`} className="flex items-start justify-between gap-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{b.itemName}</p>
<p className="text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'} · {fmtNum(b.quantity)} units · {fmtDate(b.expiryDate)}
</p>
</div>
<Badge variant={b.status === 'Expired' ? 'destructive' : 'outline'}>
{b.status === 'Expired' ? 'Expired' : 'Expiring'}
</Badge>
</li>
))}
</ul>
)}
</Section>
</div>
<Section title="Stock on hand" description={`${data.stock.length} items held in this warehouse`}>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input
value={search}
onChange={e => { setSearch(e.target.value); setVisible(PAGE_SIZE) }}
placeholder="Search item or SKU"
className="w-56"
/>
<Select value={category} onValueChange={v => { setCategory(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-44"><SelectValue placeholder="Category" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
<Select value={status} onValueChange={v => { setStatus(v ?? ALL); setVisible(PAGE_SIZE) }}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="OK">In stock</SelectItem>
<SelectItem value="Low">Low stock</SelectItem>
<SelectItem value="Out">Out of stock</SelectItem>
</SelectContent>
</Select>
</div>
{rows.length === 0 ? (
<EmptyNote>No items match the current filters.</EmptyNote>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">On hand</TableHead>
<TableHead className="text-right">Reorder at</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Cost value</TableHead>
<TableHead className="text-right">Sale value</TableHead>
<TableHead>Updated</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, visible).map(r => {
const badge = STATUS_BADGE[r.status]
const hasBatches = r.batches.length > 0
return (
<Fragment key={r.itemId}>
<TableRow
className={hasBatches ? 'cursor-pointer' : undefined}
onClick={hasBatches ? () => setExpanded(expanded === r.itemId ? null : r.itemId) : undefined}
>
<TableCell>
<p className="font-medium">{r.name}</p>
<p className="text-xs text-muted-foreground">
{r.sku}{r.brand ? ` · ${r.brand}` : ''}{hasBatches ? ` · ${r.batches.length} batch${r.batches.length > 1 ? 'es' : ''}` : ''}
</p>
</TableCell>
<TableCell>{r.category}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.quantity)} {r.uom}</TableCell>
<TableCell className="text-right tabular-nums">{r.reorderLevel != null ? fmtNum(r.reorderLevel) : '—'}</TableCell>
<TableCell><Badge variant={badge.variant}>{badge.label}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.costValue)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.saleValue)}</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">{daysAgo(r.updatedAt)}</TableCell>
<TableCell className="text-right">
{!hasBatches && (
<Button variant="ghost" size="xs" onClick={e => { e.stopPropagation(); openAdjust(r) }}>
Adjust
</Button>
)}
</TableCell>
</TableRow>
{expanded === r.itemId && r.batches.map(b => (
<TableRow key={b.batchId} className="bg-muted/40">
<TableCell colSpan={2} className="pl-8 text-xs text-muted-foreground">
Batch {b.batchNumber ?? '—'}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtNum(b.quantity)} {r.uom}</TableCell>
<TableCell colSpan={6} className="text-xs text-muted-foreground">
{b.expiryDate ? `Expires ${fmtDate(b.expiryDate)}` : 'No expiry date'}
</TableCell>
</TableRow>
))}
</Fragment>
)
})}
</TableBody>
</Table>
</div>
)}
{rows.length > visible && (
<div className="mt-3 flex justify-center">
<Button variant="outline" size="sm" onClick={() => setVisible(v => v + PAGE_SIZE)}>
Show more ({rows.length - visible} remaining)
</Button>
</div>
)}
</Section>
<StockAdjustDialog
open={adjusting !== null}
onClose={() => setAdjusting(null)}
onSuccess={() => { setAdjusting(null); onChanged() }}
balance={adjusting}
/>
</div>
)
}
@@ -0,0 +1,235 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toast } from '@/components/ui/toast'
import { warehouses as warehousesApi, getApiError } from '@/lib/api/index'
import type { WarehouseDashboardResponse } from '@/types/api'
import { WarehouseSheet } from '../warehouse-sheet'
import { ActivitySection } from './activity-section'
import { WAREHOUSE_TYPE_LABEL, daysFromToday, fmtDate, fmtMoney, fmtNum } from './format'
import { MovementSection } from './movement-section'
import { Metric } from './shared'
import { StockSection } from './stock-section'
const PRESETS = [
{ label: '7 days', days: 7 },
{ label: '30 days', days: 30 },
{ label: '90 days', days: 90 },
]
type Alert = { severity: 'danger' | 'warning' | 'info'; text: string }
function buildAlerts(d: WarehouseDashboardResponse): Alert[] {
const alerts: Alert[] = []
const { kpis, losses, inbound, outbound } = d
if (kpis.outOfStockCount > 0)
alerts.push({ severity: 'danger', text: `${kpis.outOfStockCount} item${kpis.outOfStockCount > 1 ? 's' : ''} out of stock` })
const low = d.stock.filter(r => r.status === 'Low')
if (low.length > 0) {
const names = low.slice(0, 3).map(r => `${r.name} (${fmtNum(r.quantity)}/${fmtNum(r.reorderLevel ?? 0)})`).join(', ')
alerts.push({ severity: 'warning', text: `${low.length} item${low.length > 1 ? 's' : ''} below reorder level: ${names}${low.length > 3 ? '…' : ''}` })
}
if (kpis.expiredCount > 0)
alerts.push({ severity: 'danger', text: `${kpis.expiredCount} expired batch${kpis.expiredCount > 1 ? 'es' : ''} still in stock` })
if (kpis.nearExpiryCount > 0)
alerts.push({ severity: 'warning', text: `${kpis.nearExpiryCount} batch${kpis.nearExpiryCount > 1 ? 'es' : ''} expiring within 30 days` })
if (losses.openClaimCount > 0)
alerts.push({ severity: 'warning', text: `${losses.openClaimCount} open supplier claim${losses.openClaimCount > 1 ? 's' : ''} on goods received here` })
if (inbound.rejectionRatePercent > 5)
alerts.push({ severity: 'warning', text: `GRN rejection rate is ${inbound.rejectionRatePercent}% this period` })
if (losses.negativeAdjustmentUnits > 0)
alerts.push({ severity: 'warning', text: `${fmtNum(losses.negativeAdjustmentUnits)} units adjusted down this period` })
if (losses.damageUnits > 0)
alerts.push({ severity: 'warning', text: `${fmtNum(losses.damageUnits)} units written off as damaged this period` })
if (outbound.pendingDispatchUnits > 0)
alerts.push({ severity: 'info', text: `${fmtNum(outbound.pendingDispatchUnits)} units reserved on planned loading sheets` })
return alerts
}
const ALERT_BADGE = {
danger: { label: 'Critical', variant: 'destructive' },
warning: { label: 'Attention', variant: 'outline' },
info: { label: 'Info', variant: 'secondary' },
} as const
export function WarehouseDashboard() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [from, setFrom] = useState(() => daysFromToday(-29))
const [to, setTo] = useState(() => daysFromToday(0))
const [data, setData] = useState<WarehouseDashboardResponse | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [loadedKey, setLoadedKey] = useState('')
const [editOpen, setEditOpen] = useState(false)
const requestKey = `${id}|${from}|${to}|${reloadKey}`
const loading = loadedKey !== requestKey
const reload = () => setReloadKey(k => k + 1)
useEffect(() => {
let cancelled = false
warehousesApi.dashboard(id, { from, to })
.then(res => { if (!cancelled) setData(res) })
.catch(err => {
if (!cancelled) toast.add({ title: 'Failed to load warehouse dashboard', description: getApiError(err), type: 'error' })
})
.finally(() => { if (!cancelled) setLoadedKey(requestKey) })
return () => { cancelled = true }
}, [id, from, to, requestKey])
const alerts = useMemo(() => (data ? buildAlerts(data) : []), [data])
function applyPreset(days: number) {
setFrom(daysFromToday(-(days - 1)))
setTo(daysFromToday(0))
}
const warehouse = data?.warehouse
const k = data?.kpis
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/network/warehouses')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{!warehouse ? (
<Skeleton className="h-7 w-48 rounded-lg" />
) : (
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">{warehouse.name}</h1>
<Badge variant="outline">{WAREHOUSE_TYPE_LABEL[warehouse.type] ?? warehouse.type}</Badge>
<Badge variant={warehouse.isActive ? 'default' : 'secondary'}>
{warehouse.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
{warehouse
? `${warehouse.location} · Created ${fmtDate(warehouse.createdAt)}`
: 'Warehouse overview'}
{data && data.linkedVehicles.length > 0 && (
<> · Vehicles: {data.linkedVehicles.map(v => v.numberPlate).join(', ')}</>
)}
</p>
</div>
</div>
{warehouse && (
<Button onClick={() => setEditOpen(true)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit warehouse
</Button>
)}
</div>
{/* Period */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Activity period</span>
{PRESETS.map(p => (
<Button key={p.days} variant="outline" size="sm" onClick={() => applyPreset(p.days)}>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => e.target.value && setFrom(e.target.value)} className="w-40" />
<span className="text-sm text-muted-foreground">to</span>
<Input type="date" value={to} min={from} max={daysFromToday(0)} onChange={e => e.target.value && setTo(e.target.value)} className="w-40" />
</div>
{!data && loading && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)}
{data && k && (
<div className={`flex flex-col gap-6 transition-opacity ${loading ? 'opacity-60' : ''}`}>
{/* KPIs */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric label="Stock value" value={fmtMoney(k.stockValueCost)} sub={`${fmtMoney(k.stockValueSale)} at sale price`} />
<Metric label="Units on hand" value={fmtNum(k.totalUnits)} sub={`${k.skuCount} SKUs`} />
<Metric
label="Low / out of stock"
value={`${k.lowStockCount} / ${k.outOfStockCount}`}
tone={k.outOfStockCount > 0 ? 'danger' : k.lowStockCount > 0 ? 'warning' : undefined}
sub="items"
/>
<Metric
label="Expiry risk"
value={`${k.nearExpiryCount} / ${k.expiredCount}`}
tone={k.expiredCount > 0 ? 'danger' : k.nearExpiryCount > 0 ? 'warning' : undefined}
sub="near / expired batches"
/>
<Metric label="Units in" value={fmtNum(k.unitsIn)} sub="this period" />
<Metric label="Units out" value={fmtNum(k.unitsOut)} sub="this period" />
</div>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing needs attention right now.</p>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{alerts.map((a, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Badge variant={ALERT_BADGE[a.severity].variant} className="mt-0.5 shrink-0">
{ALERT_BADGE[a.severity].label}
</Badge>
<span>{a.text}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Tabs defaultValue="stock">
<TabsList>
<TabsTrigger value="stock">Stock on hand</TabsTrigger>
<TabsTrigger value="movement">Stock movement</TabsTrigger>
<TabsTrigger value="activity">Module activity</TabsTrigger>
</TabsList>
<TabsContent value="stock"><StockSection data={data} onChanged={reload} /></TabsContent>
<TabsContent value="movement"><MovementSection data={data} /></TabsContent>
<TabsContent value="activity"><ActivitySection data={data} /></TabsContent>
</Tabs>
</div>
)}
<WarehouseSheet
open={editOpen}
onClose={() => setEditOpen(false)}
warehouse={warehouse ?? null}
onSuccess={reload}
/>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -15,6 +16,7 @@ import type { WarehouseResponse } from '@/types/api'
import { WarehouseSheet } from './warehouse-sheet'
export function WarehousesClient() {
const router = useRouter()
const [data, setData] = useState<WarehouseResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -118,6 +120,11 @@ export function WarehousesClient() {
],
rowActions: {
actions: () => [
{
action: 'view',
label: 'View dashboard',
onClick: (warehouse: WarehouseResponse) => router.push(`/network/warehouses/${warehouse.id}`),
},
{
action: 'edit',
label: 'Edit warehouse',
@@ -137,7 +144,7 @@ export function WarehousesClient() {
},
],
},
}), [pageSize, openEdit, toggleActive])
}), [pageSize, openEdit, toggleActive, router])
return (
<>
@@ -48,8 +48,8 @@ export function ConfirmLoadingSheetDialog({ open, onClose, onSuccess, sheet }: C
useEffect(() => {
if (!open) return
form.reset({ confirmedByUserId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -168,7 +168,7 @@ function LoadingSheetForm() {
regionsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
ridesApi.list({ status: isEdit ? undefined : 'Open', pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
itemsApi.list({ pageSize: 500 }),
])
.then(([regionsRes, warehousesRes, ridesRes, usersRes, itemsRes]) => {
@@ -176,7 +176,7 @@ function LoadingSheetForm() {
setRegionsList(getPaginatedItems(regionsRes))
setWarehousesList(getPaginatedItems(warehousesRes))
setRidesList(getPaginatedItems(ridesRes))
setUsersList(getPaginatedItems(usersRes))
setUsersList(usersRes)
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [isEdit])
@@ -148,14 +148,14 @@ export default function NewGrnPage() {
useEffect(() => {
Promise.all([
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
suppliersApi.list(),
itemsApi.list({ pageSize: 200 }),
purchaseOrdersApi.list({ pageSize: 200 }),
])
.then(([ws, us, ss, its, pos]) => {
setWarehousesList(getPaginatedItems(ws))
setUsersList(getPaginatedItems(us))
setUsersList(us)
setSuppliersList(ss)
setItemsList(getPaginatedItems(its))
// Only POs that can still receive goods
@@ -66,8 +66,8 @@ export function PurchaseReturnDialog({ open, onClose, onSuccess, order, existing
useEffect(() => {
if (!open) return
form.reset({ reason: '', createdByUserId: '', items: [defaultItem()] })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -79,12 +79,12 @@ export default function NewPurchaseOrderPage() {
Promise.all([
suppliersApi.list(),
itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
])
.then(([suppliersRes, itemsRes, usersRes]) => {
setSuppliersList(suppliersRes)
setItemsList(getPaginatedItems(itemsRes))
setUsersList(getPaginatedItems(usersRes))
setUsersList(usersRes)
})
.catch((err) => {
toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' })
@@ -3,12 +3,14 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import { ExportButtons } from '@/components/shared/export-buttons'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { getApiError, getPaginatedItems } from '@/lib/api/index'
import { exportExcel, exportPdf, fetchAllPages, type ExportColumn } from '@/lib/export'
import {
productReports,
type ProductReportSummary,
@@ -71,6 +73,44 @@ const returnsColumns: TableConfig<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() {
const [tab, setTab] = useState<Tab>('stock')
const [search, setSearch] = useState('')
@@ -128,6 +168,30 @@ export function ProductReportClient() {
setTotal(0)
}
async function handleExport(format: 'excel' | 'pdf') {
const filters = (p: number, s: number) => ({
page: p, pageSize: s, search: search || undefined, from: from || undefined, to: to || undefined,
})
const range = `${from || 'start'} to ${to || 'today'}`
if (tab === 'stock') {
const rows = await fetchAllPages((p, s) =>
productReports.stock({ ...filters(p, s), lowStockOnly }).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-stock', STOCK_EXPORT, rows)
else await exportPdf('product-stock', 'Product Report: Stock', STOCK_EXPORT, rows, lowStockOnly ? 'Low stock only' : undefined)
} else if (tab === 'sales') {
const rows = await fetchAllPages((p, s) =>
productReports.sales(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-sales', SALES_EXPORT, rows)
else await exportPdf('product-sales', 'Product Report: Sales', SALES_EXPORT, rows, range)
} else {
const rows = await fetchAllPages((p, s) =>
productReports.returns(filters(p, s)).then(r => ({ items: getPaginatedItems(r), total: r.total })))
if (format === 'excel') await exportExcel('product-returns', RETURNS_EXPORT, rows)
else await exportPdf('product-returns', 'Product Report: Returns', RETURNS_EXPORT, rows, range)
}
}
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
const stockConfig = useMemo<TableConfig<ProductStockRow>>(
@@ -197,6 +261,9 @@ export function ProductReportClient() {
Low stock only
</Button>
)}
<div className="ml-auto flex items-center gap-2">
<ExportButtons onExport={handleExport} disabled={loading || total === 0} />
</div>
</div>
{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 />
}
@@ -50,8 +50,8 @@ export function ClaimSettleDialog({ open, onClose, onSuccess, claim }: ClaimSett
useEffect(() => {
if (!open) return
form.reset({ settlementAmount: 0, settledByUserId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -51,8 +51,8 @@ export function ClaimSheet({ open, onClose, onSuccess, claim }: ClaimSheetProps)
useEffect(() => {
if (!open) return
form.reset({ agencyUserId: claim?.agencyUserId ?? '', details: claim?.details ?? '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open, claim])
@@ -53,9 +53,9 @@ export function ClaimsClient() {
try {
const [claimsList, usersRes] = await Promise.all([
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)
} catch (err) {
toast.add({ title: 'Failed to load claims', description: getApiError(err), type: 'error' })
@@ -88,12 +88,12 @@ export function OrderReturnDialog({ open, onClose, onSuccess }: OrderReturnDialo
Promise.all([
shopPosApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
])
.then(([posRes, itemsRes, usersRes]) => {
setShopPOList(getPaginatedItems(posRes))
setItemsList(getPaginatedItems(itemsRes))
setUsersList(getPaginatedItems(usersRes))
setUsersList(usersRes)
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open])
@@ -84,12 +84,12 @@ export function SupplierClaimDialog({ open, onClose, onSuccess, claim }: Supplie
if (!open) return
Promise.all([
purchaseOrdersApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
])
.then(([posRes, usersRes]) => {
const orderList = getPaginatedItems(posRes)
setOrdersList(orderList)
setUsersList(getPaginatedItems(usersRes))
setUsersList(usersRes)
if (claim) {
form.reset({
@@ -57,8 +57,8 @@ export function SupplierClaimResolveDialog({ open, onClose, onSuccess, claim, mo
useEffect(() => {
if (!open) return
form.reset({ resolutionType: 'Replacement', resolutionAmount: 0, resolutionNotes: '', userId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open, mode])
@@ -43,8 +43,8 @@ export function SalesVisitsClient() {
routesApi.list({ page: 1, pageSize: 200 })
.then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive)))
.catch(() => toast.add({ title: 'Failed to load routes', type: 'error' }))
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch(() => toast.add({ title: 'Failed to load users', type: 'error' }))
}, [])
@@ -76,11 +76,11 @@ export default function EditShopPOPage() {
)
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]) => {
setPo(poRes)
setItemsList(getPaginatedItems(items))
setUsersList(getPaginatedItems(users))
setUsersList(users)
form.reset({
items: poRes.items
.filter((i) => i.isActive)
@@ -50,8 +50,8 @@ export function CancelShopPODialog({ open, onClose, onSuccess, po }: CancelShopP
useEffect(() => {
if (!open) return
form.reset({ cancelledByUserId: '', reason: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -48,8 +48,8 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
useEffect(() => {
if (!open) return
form.reset({ confirmedByUserId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -67,12 +67,12 @@ export function SalesClient() {
useEffect(() => {
Promise.all([
usersApi.list({ pageSize: 200 }),
usersApi.list(),
routesApi.list({ pageSize: 200 }),
storesApi.list({ pageSize: 200 }),
])
.then(([u, r, s]) => {
setAgentsList(getPaginatedItems(u))
setAgentsList(u)
setRoutesList(getPaginatedItems(r))
setStoresList(getPaginatedItems(s))
})
@@ -110,12 +110,12 @@ export default function NewShopPOPage() {
Promise.all([
storesApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
usersApi.list(),
])
.then(([stores, items, users]) => {
setStoresList(getPaginatedItems(stores))
setItemsList(getPaginatedItems(items))
setUsersList(getPaginatedItems(users))
setUsersList(users)
})
.catch((err) => {
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(() => {
if (!open) return
form.reset({ userId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -51,8 +51,8 @@ export function StockAdjustDialog({ open, onClose, onSuccess, balance }: StockAd
useEffect(() => {
if (!open) return
form.reset({ quantity: 0, reasonCode: '', adjustedByUserId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
@@ -45,7 +45,15 @@ export function StockBalancesClient() {
setLoading(true)
try {
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)
} catch (err) {
toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' })
@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
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 type { StoreResponse, RouteResponse } from '@/types/api'
const LocationPicker = dynamic(
() => import('../_components/location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
)
import { StoreAnalytics } from '../_components/store-analytics'
const fmtDate = (v: string) =>
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 }) {
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-sm font-medium text-foreground">{children}</span>
</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() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
@@ -66,7 +50,6 @@ export default function StoreViewPage() {
const routeName = store ? routesList.find((r) => r.id === store.routeId)?.routeName : undefined
const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
return (
<div className="flex flex-col gap-6">
@@ -103,73 +86,26 @@ export default function StoreViewPage() {
</Button>
</div>
{/* Body skeleton */}
{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>
)}
{loading && <div className="h-20 animate-pulse rounded-xl bg-muted" />}
{/* Body */}
{!loading && store && (
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — store details */}
<div className="lg:col-span-1">
<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>
<div className="flex flex-col gap-6">
<div className="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<DetailRow label="Owner">{store.ownerName ?? '—'}</DetailRow>
<DetailRow label="Contact">{store.contactNumber ?? '—'}</DetailRow>
<DetailRow label="Route">
{routeName ?? <span className="font-mono text-xs">{store.routeId}</span>}
</DetailRow>
{store.segment && (
<DetailRow label="Segment">{store.segment}</DetailRow>
)}
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
<DetailRow label="Address">{store.storeAddress}</DetailRow>
<DetailRow label="Segment">{store.segment ?? '—'}</DetailRow>
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
</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>
)
}
@@ -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) {
try {
await usersApi.update(user!.id, { fullName: values.fullName })
if (values.roleId !== user!.roleId) {
await usersApi.assignRole(user!.id, values.roleId)
}
await usersApi.update(user!.id, {
fullName: values.fullName,
roleId: values.roleId !== user!.roleId ? values.roleId : undefined,
})
toast.add({ title: 'User updated', type: 'success' })
onSuccess()
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'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
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 type { RoleResponse } from '@/types/api'
import { RoleSheet } from './role-sheet'
import { RoleViewSheet } from './role-view-sheet'
export function RolesClient() {
const router = useRouter()
const [data, setData] = useState<RoleResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<RoleResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
const [viewing, setViewing] = useState<RoleResponse | null>(null)
@@ -41,8 +40,8 @@ export function RolesClient() {
useEffect(() => { fetchData() }, [fetchData])
function openCreate() { setEditing(null); setSheetOpen(true) }
function openEdit(role: RoleResponse) { setEditing(role); setSheetOpen(true) }
function openCreate() { router.push('/users/roles/new') }
function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) }
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
async function handleDeactivate(role: RoleResponse) {
@@ -122,13 +121,6 @@ export function RolesClient() {
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
/>
<RoleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
role={editing}
onSuccess={() => fetchData()}
/>
<RoleViewSheet
open={viewOpen}
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">
<SidebarHeader>
<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">
D
</div>
{/* 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 shrink-0"
/>
<span className="truncate font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
Distribution
HexaDistro
</span>
</div>
</SidebarHeader>
@@ -114,11 +117,15 @@ export function AppSidebar() {
const visibleItems = group.items.filter((item) =>
canSee(item, permissions),
)
const groupActive = visibleItems.some(
(item) =>
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href)),
const groupActive = visibleItems.some((item) => {
const hasSiblingUnder = visibleItems.some(
s => s.href !== item.href && s.href.startsWith(item.href + '/')
)
return (
pathname === item.href ||
(!hasSiblingUnder && item.href !== '/' && pathname.startsWith(item.href + '/'))
)
})
return (
<Collapsible
@@ -150,10 +157,14 @@ export function AppSidebar() {
<CollapsibleContent>
<SidebarMenuSub>
{visibleItems.map((item) => {
const hasSiblingUnder = visibleItems.some(
s => s.href !== item.href && s.href.startsWith(item.href + '/')
)
const isActive =
pathname === item.href ||
(item.href !== '/' &&
pathname.startsWith(item.href))
(!hasSiblingUnder &&
item.href !== '/' &&
pathname.startsWith(item.href + '/'))
return (
<Tooltip key={item.href}>
<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