diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs
new file mode 100644
index 0000000..b10062f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/DashboardController.cs
@@ -0,0 +1,20 @@
+using ERPCore.Dtos.Dashboard;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Dashboard overview stats — cross-domain counts, not a stored entity.
+[Route("api/v1/dashboard")]
+public sealed class DashboardController : ApiControllerBase
+{
+ private readonly IDashboardService _dashboard;
+
+ public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
+
+ /// Aggregate counts for stock, GRN, and procurement.
+ [HttpGet("stats")]
+ [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
+ public async Task> GetStats(CancellationToken ct)
+ => Ok(await _dashboard.GetStatsAsync(ct));
+}
diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
new file mode 100644
index 0000000..857ba9a
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
@@ -0,0 +1,24 @@
+namespace ERPCore.Dtos.Dashboard;
+
+///
+/// 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).
+///
+public sealed record DashboardStatsDto(
+ int LowStockAlerts,
+ decimal OnHandTotal,
+ int OnHandWarehouses,
+ decimal StockValuationTotal,
+ IReadOnlyList StockValuationByWarehouse,
+ int PendingApprovalPurchaseOrders,
+ int PendingGrns,
+ int OpenRequisitions,
+ int PendingCounts,
+ int OpenRfqs);
+
+/// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.
+public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs
index 8b028cf..4b7ba3e 100644
--- a/Backend/ERPCore/Program.cs
+++ b/Backend/ERPCore/Program.cs
@@ -97,6 +97,9 @@ builder.Services.AddScoped();
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
builder.Services.AddScoped();
+// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
+builder.Services.AddScoped();
+
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
builder.Services.AddSingleton();
builder.Services.AddScoped();
diff --git a/Backend/ERPCore/Services/DashboardService.cs b/Backend/ERPCore/Services/DashboardService.cs
new file mode 100644
index 0000000..1951559
--- /dev/null
+++ b/Backend/ERPCore/Services/DashboardService.cs
@@ -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;
+
+///
+/// Aggregate counts pulled straight from each domain's repository — no PagedResponse
+/// overhead, since the dashboard only needs totals. Low-stock reuses
+/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7).
+/// On-hand summary and stock valuation sum (and
+/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts,
+/// because they need no per-item live lookup.
+///
+public sealed class DashboardService : IDashboardService
+{
+ private readonly IRepository _layers;
+ private readonly IRepository _grns;
+ private readonly IRepository _pos;
+ private readonly IRepository _requisitions;
+ private readonly IRepository _counts;
+ private readonly IRepository _rfqs;
+ private readonly IReorderService _reorder;
+
+ public DashboardService(
+ IRepository layers, IRepository grns, IRepository pos,
+ IRepository requisitions, IRepository counts, IRepository rfqs,
+ IReorderService reorder)
+ {
+ _layers = layers;
+ _grns = grns;
+ _pos = pos;
+ _requisitions = requisitions;
+ _counts = counts;
+ _rfqs = rfqs;
+ _reorder = reorder;
+ }
+
+ public async Task 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);
+ }
+}
diff --git a/Backend/ERPCore/Services/Interfaces/IDashboardService.cs b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs
new file mode 100644
index 0000000..3d90a88
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs
@@ -0,0 +1,9 @@
+using ERPCore.Dtos.Dashboard;
+
+namespace ERPCore.Services.Interfaces;
+
+/// Cross-domain aggregate stats for the dashboard overview.
+public interface IDashboardService
+{
+ Task GetStatsAsync(CancellationToken ct = default);
+}
diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md
index debc2a8..49b419b 100644
--- a/Backend/PROGRESS.md
+++ b/Backend/PROGRESS.md
@@ -181,6 +181,13 @@ Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (
## Done
+### 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.
diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md
index d0f8725..817c9c1 100644
--- a/Frontend/PROGRESS.md
+++ b/Frontend/PROGRESS.md
@@ -118,6 +118,16 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md`
## Done
+### 2026-07-28 — Dashboard overview (`app/dashboard/page.tsx`)
+- **Replaced the component-showcase placeholder with a real stats dashboard.** 7 `StatCard` tiles (Low Stock Alerts, Stock On-Hand, Pending Approval POs, Pending GRNs, Open Requisitions, Open Counts, Active RFQs), all wired to the new `GET /dashboard/stats` (`lib/api/dashboard.ts`, `types/dashboard.ts`) — see `Backend/PROGRESS.md`'s matching 2026-07-28 entry for the endpoint itself. Each tile links to its source list page.
+- **Stock Valuation by Warehouse** — `BarChart` over `stats.stockValuationByWarehouse`, warehouse codes resolved via `warehousesApi.list()`.
+- **Stock Movement Trend** — `LineChart`, 14-day In/Out totals bucketed client-side from `GET /stock/ledger?from=...&pageSize=200`. **Falls back to a hardcoded sample series (`SAMPLE_TREND_IN`/`OUT`) when the real ledger has no activity in that window**, so the chart isn't a flat zero line on a fresh/demo database — real data always wins when present. (An equivalent fallback was added to the Recent Stock Movements table during this pass and then explicitly removed at the user's request — that table shows only real data + an empty state.)
+- **Recent Stock Movements** — table of the latest 5 ledger entries; shows `#itemId` rather than the item SKU, deliberately, to avoid a hard dependency on `GET /items` (see the bug below).
+- **`StatCard` (`components/ui/stat-card.tsx`) fixed to use theme tokens** — it previously hardcoded `bg-white`/`text-slate-900`/`text-indigo-600`/`ring-black/5`, which was invisible-on-dark once the Dark/Vibrant themes existed. Now `bg-card`/`text-foreground`/`text-primary`/`ring-foreground/10`.
+- **Bug found — `GET /items` 500s on every call** (`column i.SalePrice does not exist`) — this is why the dashboard and the movements table avoid `itemsApi` entirely. Root cause + fix status tracked in `Backend/PROGRESS.md`'s 2026-07-28 entry; **not yet fixed** as of this entry.
+- **Chart color gotcha (found and fixed twice this session):** passing a CSS custom property or `color-mix()` string (e.g. `"var(--color-primary)"`) as a Chart.js `borderColor`/`backgroundColor` silently renders **black**, because a `