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:
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||
[Route("api/v1/dashboard")]
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
|
||||
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
|
||||
|
||||
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
|
||||
[HttpGet("stats")]
|
||||
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
|
||||
=> Ok(await _dashboard.GetStatsAsync(ct));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ERPCore.Dtos.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate counts for the dashboard overview — mirrors the widget set in
|
||||
/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation,
|
||||
/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting,
|
||||
/// and open RFQs. Recent movements isn't here — it's just GET /stock/ledger with a small
|
||||
/// pageSize, no aggregation needed. A single computed-on-read object, not a stored
|
||||
/// entity — same as reorder alerts (docs/11 §5.7).
|
||||
/// </summary>
|
||||
public sealed record DashboardStatsDto(
|
||||
int LowStockAlerts,
|
||||
decimal OnHandTotal,
|
||||
int OnHandWarehouses,
|
||||
decimal StockValuationTotal,
|
||||
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
|
||||
int PendingApprovalPurchaseOrders,
|
||||
int PendingGrns,
|
||||
int OpenRequisitions,
|
||||
int PendingCounts,
|
||||
int OpenRfqs);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
@@ -97,6 +97,9 @@ builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
|
||||
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
|
||||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||||
builder.Services.AddScoped<IBranchService, BranchService>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -181,6 +181,13 @@ Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (
|
||||
## Done
|
||||
<!-- move [x] items here with date + note if the active list grows long -->
|
||||
|
||||
### 2026-07-28 — Dashboard overview endpoint (`GET /dashboard/stats`)
|
||||
- **New cross-domain aggregate for the frontend dashboard** — `Dtos/Dashboard/DashboardDtos.cs`, `IDashboardService`/`DashboardService`, `DashboardController` (`GET /api/v1/dashboard/stats`). Mirrors `docs/dashboard-implementation.pdf`'s widget list: low-stock alerts (reuses `IReorderService.GetAlertsAsync`, `PageSize:1` since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-side `SUM`/`GROUP BY` over `StockLayer`, cheap unlike reorder alerts since they need no per-item live lookup), pending-approval POs, pending (Draft) GRNs, open (Submitted) requisitions, pending (Counted) stock counts, open RFQs. Registered in `Program.cs`. docs/11-BACKEND-PHASE1.md §5.8.
|
||||
- **Not covered:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend calls `GET /stock/ledger` directly — no aggregation needed for a small `pageSize`).
|
||||
- **Bug found + fixed during this work — `WarehouseValuationDto` construction inside `GroupBy().Select()` doesn't translate.** EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (`InvalidOperationException`, confirmed live via `logs/erpcore-20260728.log`). Fixed by projecting to an anonymous type first (`Select(g => new { g.Key, Total = ... })`), materializing with `ToListAsync`, then mapping to the DTO record client-side.
|
||||
- **Unrelated bug found while testing this — `GET /items` 500s on every call: `column i.SalePrice does not exist`.** `ItemConfiguration.cs` maps `Item.SalePrice`, but the `AddItemSalePrice` migration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed via `logs/erpcore-20260727.log`; `dotnet ef migrations add` against the current model produces an **empty** migration (no `Up`/`Down` ops), meaning the model snapshot already believes `SalePrice` exists even though the column doesn't — the snapshot and the real schema have drifted. **Not yet fixed** — needs a hand-written `AddColumn` migration (the auto-diff can't see the gap) run against this specific database; blocks the dashboard's on-hand/valuation widgets from ever showing item names, and blocks the entire Products page and every item picker (GRN/PO/ledger/valuation).
|
||||
- **Verified:** `dotnet build` clean (isolated output directory, to avoid the Visual-Studio-debugger file lock that repeatedly blocked rebuilding the live dev instance this session). Runtime-verified against the live log after a VS restart — confirmed reaching real code (not 404), the `GroupBy` bug above was caught this way. Full 200-response verification still pending the next VS restart.
|
||||
|
||||
### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`)
|
||||
- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1.
|
||||
- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change** — `GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3.
|
||||
|
||||
Reference in New Issue
Block a user