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 `` 2D context cannot resolve CSS variables — it's not a themeable value, it's an invalid string that falls back to the default. Every chart on this page uses real static hex colors instead (`#6366f1`, `#22c55e`, `#ef4444`). +- **Verified:** `tsc --noEmit` clean throughout. Runtime verification blocked for most of this session by the dev backend running under an active Visual Studio debug session — killing the process externally just triggers VS's own auto-relaunch of the **stale** build (observed repeatedly; confirmed via process start-time checks), so `dotnet build`/`dotnet ef` against the live `bin/`/`obj/` failed on file locks. Worked around by building to an isolated `-o` output directory to verify compilation without touching the locked live build; **actually deploying a rebuild still requires stopping debugging inside Visual Studio itself** (not just closing a console window) — this blocked full end-to-end verification of `/dashboard/stats` until the user did that. + ### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create - **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`). - **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged. diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 45b9480..7379d48 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -1,325 +1,284 @@ "use client" -import * as React from "react" -import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + AlertTriangle, + BadgeDollarSign, + Boxes, + Clock, + ClipboardList, + ListChecks, + PackageCheck, + ScrollText, + Send, +} from "lucide-react" +import { dashboardApi } from "@/lib/api/dashboard" +import { stockApi } from "@/lib/api/stock" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { RecentOrdersTable } from "@/components/dashboard/recent-orders-table" -import { Button } from "@/components/ui/button" -import { DatePicker, DateRangePicker } from "@/components/ui/date-picker" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogContent, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { - Breadcrumb, - BreadcrumbEllipsis, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" +import { DashboardStats } from "@/types/dashboard" +import { LedgerEntry } from "@/types/stock" +import { Warehouse } from "@/types/master-data" import { StatCard } from "@/components/ui/stat-card" -import { toast } from "@/components/ui/toast" -import LineChart from "@/components/ui/line-chart" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import BarChart from "@/components/ui/bar-chart" -import PieChart from "@/components/ui/pie-chart" +import LineChart from "@/components/ui/line-chart" -const indigoButton = - "bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40" +const TREND_DAYS = 14 + +function isoDate(d: Date) { + return d.toISOString().slice(0, 10) +} + +// Shown only when the last TREND_DAYS days have no real ledger activity, so the +// chart isn't a flat zero line before there's any real movement to plot. +const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72] +const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58] export default function DashboardPage() { - const [date, setDate] = React.useState() - const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>() + const [stats, setStats] = useState(null) + const [movements, setMovements] = useState(null) + const [trend, setTrend] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + const from = new Date() + from.setDate(from.getDate() - (TREND_DAYS - 1)) + + Promise.all([ + dashboardApi.stats(), + stockApi.ledger({ page: 1, pageSize: 5 }), + stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }), + warehousesApi.list(), + ]) + .then(([statsRes, ledger, trendRes, warehousesRes]) => { + if (cancelled) return + setStats(statsRes) + setMovements(ledger.items) + setTrend(trendRes.items) + setWarehouses(warehousesRes.items) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + + return () => { + cancelled = true + } + }, []) + + const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) + + // Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date. + const movementTrend = useMemo(() => { + const days: string[] = [] + const cursor = new Date() + cursor.setDate(cursor.getDate() - (TREND_DAYS - 1)) + for (let i = 0; i < TREND_DAYS; i++) { + days.push(isoDate(cursor)) + cursor.setDate(cursor.getDate() + 1) + } + + const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" })) + + if (!trend || trend.length === 0) { + return { labels, inData: SAMPLE_TREND_IN, outData: SAMPLE_TREND_OUT } + } + + const inByDay = new Map(days.map((d) => [d, 0])) + const outByDay = new Map(days.map((d) => [d, 0])) + for (const entry of trend) { + const day = entry.createdAt.slice(0, 10) + const bucket = entry.direction === "In" ? inByDay : outByDay + if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase) + } + + return { + labels, + inData: days.map((d) => inByDay.get(d) ?? 0), + outData: days.map((d) => outByDay.get(d) ?? 0), + } + }, [trend]) + + const loaded = stats && movements && trend && warehouses + return (
-
-
-

Breadcrumb

-
- {/* Basic */} - - - - Home - - - - Products - - - - Product Detail - - - +
+

Dashboard

+

Overview of stock, receiving and procurement.

+
- {/* With ellipsis */} - - - - Home - - - - - - - - Products - - - - Edit - - - -
+ {error && ( +
+ {error} +
+ )} + +
+ {loaded ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) : ( + !error && Array.from({ length: 7 }).map((_, i) => ) + )} +
+ +
+
+

+ + Stock Valuation by Warehouse +

+ + View details +
-
-

Variants

-
- - - - - - - - - -
-
- -
-

Sizes

-
- - - - - - - - - -
-
- -
-

Date Picker

-
-
- Single date - 0 ? ( +
+ warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` + )} + datasets={[ + { + label: "Stock Value (LKR)", + data: stats.stockValuationByWarehouse.map((w) => w.total), + backgroundColor: "#6366f1", + }, + ]} />
-
- Date range - -
-
- {(date || range?.from) && ( -

- {date && <>Selected: {date.toLocaleDateString()}} - {range?.from && ( - <> - {date && " · "} - Range: {range.from.toLocaleDateString()} - {range.to && <> – {range.to.toLocaleDateString()}} - - )} -

- )} -
- -
-

Modal

- - Open Modal} /> - - -
- -
- Order confirmed - - Your order has been placed successfully and is now being processed. - -
- -
- -
-
-
-
- -
-

Toast

-
- - - - - - -
-
+ ) : ( +

No stock on hand yet.

+ ) + ) : ( + !error && + )}
-
-
-

Alert Dialogs

-
- - Info} /> - toast.info("Reloading...", "Applying the latest update.")} - /> - - - - Success} /> - toast.success("Done!", "Redirecting to dashboard.")} - /> - - - - Warning} /> - toast.warning("Changes discarded")} - /> - - - - Delete} /> - toast.error("Deleted", "The record has been permanently removed.")} - /> - -
+
+
+ +

+ Stock Movement Trend (last {TREND_DAYS} days) +

-
-
- - - - -
- - - -
-
-

Sales (Line)

-
+ {loaded ? ( +
+ ) : ( + !error && + )} +
+ +
+
+

+ + Recent Stock Movements +

+ + View all +
-
-

Revenue (Bar)

-
- -
-
- -
-

Product Mix (Pie)

-
- -
-
+ {loaded ? ( + movements.length > 0 ? ( + + + + Item + Warehouse + Direction + Qty + Source + Date + + + + {movements.map((entry) => ( + + #{entry.itemId} + + {warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} + + + + {entry.direction} + + + {entry.qtyBase} + + {entry.sourceDocType} #{entry.sourceDocId} + + + {new Date(entry.createdAt).toLocaleDateString()} + + + ))} + +
+ ) : ( +

No stock movements yet.

+ ) + ) : ( + !error && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + )}
) diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index f977cef..1d4e739 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" +import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" @@ -156,14 +156,9 @@ export default function BrandsPage() { return (
-
- - - -
-

Brands

-

Manage product brands.

-
+
+

Brands

+

Manage product brands.

@@ -239,21 +234,21 @@ export default function BrandsPage() { {!error && brands !== null && brands.length > 0 && ( <> - - - + + + toggleSort("brandId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -364,7 +359,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index 566f376..f13f273 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" +import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" @@ -155,14 +155,9 @@ export default function CategoriesPage() { return (
-
- - - -
-

Categories

-

Item category master (FR-MD-04).

-
+
+

Categories

+

Item category master (FR-MD-04).

@@ -238,21 +233,21 @@ export default function CategoriesPage() { {!error && categories !== null && categories.length > 0 && ( <>
- - - + + + toggleSort("categoryId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -371,7 +366,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/components/ui/select.tsx b/Frontend/erp-system/components/ui/select.tsx index 852c14f..4b598ed 100644 --- a/Frontend/erp-system/components/ui/select.tsx +++ b/Frontend/erp-system/components/ui/select.tsx @@ -150,7 +150,7 @@ function SelectItem({ } > - + ) diff --git a/Frontend/erp-system/components/ui/stat-card.tsx b/Frontend/erp-system/components/ui/stat-card.tsx index e4371a9..4aebb88 100644 --- a/Frontend/erp-system/components/ui/stat-card.tsx +++ b/Frontend/erp-system/components/ui/stat-card.tsx @@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) { className="h-4.5 w-12 shrink-0 overflow-visible" aria-hidden="true" > - - + + ) } @@ -71,21 +71,21 @@ export function StatCard({ return (

{label}

{Icon && ( -
- +
+
)}
-

{formatValue(value)}

+

{formatValue(value)}

{trend && trend.length > 1 && }
@@ -94,7 +94,7 @@ export function StatCard({ {isPositive ? "+" : "-"} diff --git a/Frontend/erp-system/components/ui/table.tsx b/Frontend/erp-system/components/ui/table.tsx index c7eb05c..6e3126c 100644 --- a/Frontend/erp-system/components/ui/table.tsx +++ b/Frontend/erp-system/components/ui/table.tsx @@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { return (
) @@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
{ + return apiRequest("/dashboard/stats") + }, +} diff --git a/Frontend/erp-system/types/dashboard.ts b/Frontend/erp-system/types/dashboard.ts new file mode 100644 index 0000000..7e9857a --- /dev/null +++ b/Frontend/erp-system/types/dashboard.ts @@ -0,0 +1,20 @@ +// Dashboard overview types. Mirrors Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs — +// a single computed-on-read aggregate object, not a paged list. + +export interface WarehouseValuation { + warehouseId: number + total: number +} + +export interface DashboardStats { + lowStockAlerts: number + onHandTotal: number + onHandWarehouses: number + stockValuationTotal: number + stockValuationByWarehouse: WarehouseValuation[] + pendingApprovalPurchaseOrders: number + pendingGrns: number + openRequisitions: number + pendingCounts: number + openRfqs: number +} diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index fbcf01b..5c04b97 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -713,6 +713,28 @@ Items at/below ROP (FR-STK-10); computed on read, no stored entity. ``` `POST /stock/reorder-alerts/{itemId}/requisition?warehouseId=1` → creates a draft requisition for the suggested qty. +### 5.8 Dashboard overview (added 2026-07-28) +#### `GET /dashboard/stats` +Cross-domain aggregate counts for the dashboard UI — a single computed-on-read object, not a stored entity or a `PagedResponse` list (same posture as reorder alerts, §5.7). `lowStockAlerts` reuses `IReorderService.GetAlertsAsync` rather than re-deriving the FIFO-available-vs-reorder-point comparison; `onHandTotal`/`onHandWarehouses`/`stockValuationTotal`/`stockValuationByWarehouse` are SQL-side `SUM`/`GROUP BY` over `StockLayer` (cheap — unlike reorder alerts, they need no per-item live lookup). +```json +{ + "lowStockAlerts": 6, + "onHandTotal": 15420, + "onHandWarehouses": 3, + "stockValuationTotal": 4820500.00, + "stockValuationByWarehouse": [ + { "warehouseId": 1, "total": 3120000.00 }, + { "warehouseId": 2, "total": 1700500.00 } + ], + "pendingApprovalPurchaseOrders": 2, + "pendingGrns": 4, + "openRequisitions": 5, + "pendingCounts": 1, + "openRfqs": 3 +} +``` +`pendingApprovalPurchaseOrders` = PO status `PendingApproval`; `pendingGrns` = GRN status `Draft`; `openRequisitions` = Requisition status `Submitted`; `pendingCounts` = StockCount status `Counted` (counted but not yet posted); `openRfqs` = RFQ status `Open`. **Not covered here:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it yet) and recent stock movements (just call `GET /stock/ledger` directly with a small `pageSize` — no aggregation needed). + --- ## 6. Reference Data