feat: add dashboard overview stats endpoint and UI integration

- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
This commit is contained in:
2026-07-28 14:59:09 +05:30
parent 0b95d6f1cd
commit b12adebaa0
16 changed files with 496 additions and 344 deletions
@@ -0,0 +1,79 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Dashboard;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Aggregate counts pulled straight from each domain's repository — no PagedResponse
/// overhead, since the dashboard only needs totals. Low-stock reuses <see cref="IReorderService"/>
/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7).
/// On-hand summary and stock valuation sum <see cref="StockLayer.QtyRemaining"/> (and
/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts,
/// because they need no per-item live lookup.
/// </summary>
public sealed class DashboardService : IDashboardService
{
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<Grn> _grns;
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<Requisition> _requisitions;
private readonly IRepository<StockCount> _counts;
private readonly IRepository<Rfq> _rfqs;
private readonly IReorderService _reorder;
public DashboardService(
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IReorderService reorder)
{
_layers = layers;
_grns = grns;
_pos = pos;
_requisitions = requisitions;
_counts = counts;
_rfqs = rfqs;
_reorder = reorder;
}
public async Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default)
{
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations.
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
var onHandWarehouses = await _layers.Query().AsNoTracking()
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
var stockValuationTotal = await _layers.Query().AsNoTracking()
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record.
var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking()
.GroupBy(l => l.WarehouseId)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.OrderByDescending(w => w.Total)
.ToListAsync(ct);
var stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList();
var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct);
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var pendingCounts = await _counts.Query().AsNoTracking()
.CountAsync(c => c.Status == CountStatus.Counted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs);
}
}
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Dashboard;
namespace ERPCore.Services.Interfaces;
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService
{
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
}