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,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);
+3
View File
@@ -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);
}
+7
View File
@@ -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.
+10
View File
@@ -118,6 +118,16 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + 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 (`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 `<canvas>` 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.
+251 -292
View File
@@ -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<Date>()
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
<div className="flex flex-col gap-3">
{/* Basic */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Product Detail</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* With ellipsis */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbEllipsis />
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="/dashboard/products">Products</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Edit</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p>
<div className="flex flex-wrap gap-2">
<Button variant="default">Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="success">Success</Button>
<Button variant="warning">Warning</Button>
<Button variant="info">Info</Button>
<Button variant="link">Link</Button>
</div>
{error && (
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{error}
</div>
)}
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p>
<div className="flex flex-wrap items-center gap-3">
<Button size="xs" className={indigoButton}>
Extra Small
</Button>
<Button size="sm" className={indigoButton}>
Small
</Button>
<Button size="default" className={indigoButton}>
Default
</Button>
<Button size="lg" className={indigoButton}>
Large
</Button>
<Button size="lg" className={cn(indigoButton, "h-12 px-8 text-base")}>
Extra Large
</Button>
<Button size="icon-sm" className={indigoButton} aria-label="Add (small)">
<Plus />
</Button>
<Button size="icon" className={indigoButton} aria-label="Add">
<Plus />
</Button>
<Button size="icon-lg" className={indigoButton} aria-label="Add (large)">
<Plus />
</Button>
<Button
size="icon-lg"
className={cn(indigoButton, "size-12")}
aria-label="Add (extra large)"
>
<Plus className="size-6" />
</Button>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Date Picker</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Single date</span>
<DatePicker
value={date}
onChange={setDate}
placeholder="Select a date"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Date range</span>
<DateRangePicker
value={range}
onChange={setRange}
placeholder="Select date range"
/>
</div>
</div>
{(date || range?.from) && (
<p className="mt-2 text-xs text-muted-foreground">
{date && <>Selected: <span className="font-medium text-foreground">{date.toLocaleDateString()}</span></>}
{range?.from && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
{loaded ? (
<>
{date && " · "}
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span>
{range.to && <> <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>}
<Link href="/dashboard/stock/reorder-alerts">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
</Link>
<Link href="/dashboard/stock/enquiry">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
</Link>
<Link href="/dashboard/procurement/purchase-orders">
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
</Link>
<Link href="/dashboard/receiving/grn">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
</Link>
<Link href="/dashboard/procurement/requisitions">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} />
</Link>
<Link href="/dashboard/stock/counts">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} />
</Link>
<Link href="/dashboard/procurement/rfqs">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} />
</Link>
</>
)}
</p>
) : (
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)}
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p>
<Dialog>
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} />
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4">
<div className="flex size-14 items-center justify-center rounded-full bg-success/10">
<CheckCircle2 className="size-7 text-success" />
</div>
<DialogTitle className="text-lg font-bold">Order confirmed</DialogTitle>
<DialogDescription>
Your order has been placed successfully and is now being processed.
</DialogDescription>
</DialogHeader>
<div className="flex justify-center px-2 pb-2 sm:px-4">
<Button size="lg" className="w-full sm:w-auto sm:px-10">
Continue
</Button>
</div>
</DialogContent>
</Dialog>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
</Link>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => toast({ title: "Default toast message" })}>
Default
</Button>
<Button variant="success" onClick={() => toast.success("Success!", "Your changes have been saved.")}>
Success
</Button>
<Button variant="destructive" onClick={() => toast.error("Error", "Something went wrong. Please try again.")}>
Error
</Button>
<Button variant="warning" onClick={() => toast.warning("Warning", "This action cannot be undone.")}>
Warning
</Button>
<Button variant="info" onClick={() => toast.info("Info", "Your session will expire in 5 minutes.")}>
Info
</Button>
<Button
variant="secondary"
onClick={() =>
toast({
title: "With Action",
description: "Do you want to undo this change?",
actionLabel: "Undo",
onAction: () => toast.success("Undone!", "Change has been reverted."),
})
}
>
With Action
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-5 rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Alert Dialogs</p>
<div className="flex flex-wrap gap-2">
<AlertDialog>
<AlertDialogTrigger render={<Button variant="info">Info</Button>} />
<AlertDialogContent
variant="info"
title="Update available"
description="A new version is ready. Reload the page to apply the latest changes."
confirmLabel="Reload"
onConfirm={() => toast.info("Reloading...", "Applying the latest update.")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="success">Success</Button>} />
<AlertDialogContent
variant="success"
title="Order confirmed"
description="Your order has been placed successfully and is now being processed."
confirmLabel="Continue"
cancelLabel="View order"
onConfirm={() => toast.success("Done!", "Redirecting to dashboard.")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="warning">Warning</Button>} />
<AlertDialogContent
variant="warning"
title="Unsaved changes"
description="You have unsaved changes. Leaving this page will discard them."
confirmLabel="Leave anyway"
onConfirm={() => toast.warning("Changes discarded")}
/>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger render={<Button variant="destructive">Delete</Button>} />
<AlertDialogContent
variant="destructive"
title="Delete record?"
description="This action is permanent and cannot be undone. All associated data will be removed."
confirmLabel="Delete"
onConfirm={() => toast.error("Deleted", "The record has been permanently removed.")}
/>
</AlertDialog>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Total Revenue" value={45231.89} icon={DollarSign} />
<StatCard label="Orders" value={1284} icon={ShoppingCart} />
<StatCard label="Customers" value={892} icon={Users} />
<StatCard label="Low Stock Items" value={16} icon={Package} />
</div>
<RecentOrdersTable />
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Sales (Line)</h3>
<div className="h-56">
<LineChart
labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]}
datasets={[{ label: "Sales", data: [120, 200, 150, 220, 180, 260] }]}
/>
</div>
</div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Revenue (Bar)</h3>
<div className="h-56">
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<BarChart
labels={["Q1", "Q2", "Q3", "Q4"]}
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]}
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
<div className="h-56">
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<ScrollText className="size-4 shrink-0 text-primary" />
Recent Stock Movements
</h2>
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View all
</Link>
</div>
{loaded ? (
movements.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
<TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell className="text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
) : (
!error && (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
)
)}
</div>
</div>
)
@@ -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,15 +156,10 @@ export default function BrandsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
<p className="text-base text-muted-foreground">Manage product brands.</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
@@ -239,21 +234,21 @@ export default function BrandsPage() {
{!error && brands !== null && brands.length > 0 && (
<>
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -364,7 +359,7 @@ function SortableHeader({
return (
<button
type="button"
className="flex items-center gap-1 hover:text-indigo-900"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
@@ -376,7 +371,7 @@ function SortableHeader({
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
@@ -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,15 +155,10 @@ export default function CategoriesPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
@@ -238,21 +233,21 @@ export default function CategoriesPage() {
{!error && categories !== null && categories.length > 0 && (
<>
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -371,7 +366,7 @@ function SortableHeader({
return (
<button
type="button"
className="flex items-center gap-1 hover:text-indigo-900"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
@@ -383,7 +378,7 @@ function SortableHeader({
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
+2 -2
View File
@@ -150,7 +150,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-violet-100 focus:text-violet-900 dark:focus:bg-violet-500/25 dark:focus:text-violet-200 not-data-[variant=destructive]:focus:**:text-violet-900 dark:not-data-[variant=destructive]:focus:**:text-violet-200 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
@@ -163,7 +163,7 @@ function SelectItem({
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
<CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
@@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) {
className="h-4.5 w-12 shrink-0 overflow-visible"
aria-hidden="true"
>
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} className="fill-indigo-600" stroke="white" strokeWidth={1.5} />
<path d={path} fill="none" className="stroke-muted-foreground/40" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
</svg>
)
}
@@ -71,21 +71,21 @@ export function StatCard({
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200",
"flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-base font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
<Icon className="size-5 text-indigo-600" />
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="size-5 text-primary" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-xl font-bold tracking-tight text-slate-900">{formatValue(value)}</p>
<p className="text-xl font-bold tracking-tight text-foreground">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
@@ -94,7 +94,7 @@ export function StatCard({
<span
className={cn(
"font-semibold",
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
isGood ? "text-success" : "text-destructive"
)}
>
{isPositive ? "+" : "-"}
+2 -2
View File
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("bg-primary/5 [&_tr]:border-b [&_tr]:hover:bg-primary/5", className)}
className={cn("bg-muted/50 [&_tr]:border-b [&_tr]:hover:bg-muted/50", className)}
{...props}
/>
)
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-semibold whitespace-nowrap text-primary [&:has([role=checkbox])]:pr-0",
"h-10 px-2 text-left align-middle font-semibold whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
+9
View File
@@ -0,0 +1,9 @@
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
import { apiRequest } from "@/lib/api-client"
import { DashboardStats } from "@/types/dashboard"
export const dashboardApi = {
stats(): Promise<DashboardStats> {
return apiRequest<DashboardStats>("/dashboard/stats")
},
}
+20
View File
@@ -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
}
+22
View File
@@ -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<T>` 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