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)
|
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
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
|
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
|
||||||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||||||
builder.Services.AddScoped<IBranchService, BranchService>();
|
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
|
## Done
|
||||||
<!-- move [x] items here with date + note if the active list grows long -->
|
<!-- 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`)
|
### 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md`
|
|||||||
## Done
|
## Done
|
||||||
<!-- move [x] items here with date + note if the active list grows long -->
|
<!-- 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
|
### 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`).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -1,325 +1,284 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-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 { cn } from "@/lib/utils"
|
||||||
import { RecentOrdersTable } from "@/components/dashboard/recent-orders-table"
|
import { DashboardStats } from "@/types/dashboard"
|
||||||
import { Button } from "@/components/ui/button"
|
import { LedgerEntry } from "@/types/stock"
|
||||||
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"
|
import { Warehouse } from "@/types/master-data"
|
||||||
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 { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import LineChart from "@/components/ui/line-chart"
|
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 BarChart from "@/components/ui/bar-chart"
|
||||||
import PieChart from "@/components/ui/pie-chart"
|
import LineChart from "@/components/ui/line-chart"
|
||||||
|
|
||||||
const indigoButton =
|
const TREND_DAYS = 14
|
||||||
"bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40"
|
|
||||||
|
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() {
|
export default function DashboardPage() {
|
||||||
const [date, setDate] = React.useState<Date>()
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>()
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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>
|
<div>
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Breadcrumb</p>
|
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
|
||||||
<div className="flex flex-col gap-3">
|
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{error && (
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Variants</p>
|
<div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||||
<div className="flex flex-wrap gap-2">
|
{error}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Sizes</p>
|
{loaded ? (
|
||||||
<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 && (
|
|
||||||
<>
|
<>
|
||||||
{date && " · "}
|
<Link href="/dashboard/stock/reorder-alerts">
|
||||||
Range: <span className="font-medium text-foreground">{range.from.toLocaleDateString()}</span>
|
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} />
|
||||||
{range.to && <> – <span className="font-medium text-foreground">{range.to.toLocaleDateString()}</span></>}
|
</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>
|
||||||
|
|
||||||
<div>
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Modal</p>
|
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Dialog>
|
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
<DialogTrigger render={<Button variant="outline">Open Modal</Button>} />
|
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
|
||||||
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-md">
|
Stock Valuation by Warehouse
|
||||||
<DialogHeader className="items-center gap-3 px-2 pt-4 text-center sm:px-4">
|
</h2>
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-success/10">
|
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||||
<CheckCircle2 className="size-7 text-success" />
|
View details
|
||||||
</div>
|
</Link>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
{loaded ? (
|
||||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Toast</p>
|
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="h-52 sm:h-64">
|
||||||
<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">
|
|
||||||
<BarChart
|
<BarChart
|
||||||
labels={["Q1", "Q2", "Q3", "Q4"]}
|
labels={stats.stockValuationByWarehouse.map(
|
||||||
datasets={[{ label: "Revenue", data: [30000, 42000, 36000, 48000], backgroundColor: "var(--color-primary)" }]}
|
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
|
||||||
|
)}
|
||||||
|
datasets={[
|
||||||
|
{
|
||||||
|
label: "Stock Value (LKR)",
|
||||||
|
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-xl bg-card p-6 shadow-sm border border-gray-200">
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
<h3 className="mb-4 text-sm font-semibold text-foreground">Product Mix (Pie)</h3>
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<div className="h-56">
|
<ScrollText className="size-4 shrink-0 text-primary" />
|
||||||
<PieChart labels={["A","B","C"]} data={[45, 30, 25]} />
|
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
|
Stock Movement Trend (last {TREND_DAYS} days)
|
||||||
|
</h2>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { brandsApi } from "@/lib/api/brands"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -156,15 +156,10 @@ export default function BrandsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
||||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
|
<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 && (
|
{!error && brands !== null && brands.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -364,7 +359,7 @@ function SortableHeader({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 hover:text-indigo-900"
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||||
>
|
>
|
||||||
@@ -376,7 +371,7 @@ function SortableHeader({
|
|||||||
<ArrowDown className="size-3.5" />
|
<ArrowDown className="size-3.5" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { categoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -155,15 +155,10 @@ export default function CategoriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
<h1 className="text-2xl font-bold text-foreground">Categories</h1>
|
||||||
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
<p className="text-base text-muted-foreground">Item category master (FR-MD-04).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />New Category</Button>} />
|
<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 && (
|
{!error && categories !== null && categories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader className="bg-indigo-50">
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-indigo-50">
|
<TableRow>
|
||||||
<TableHead className="h-12 px-3 text-sm text-indigo-700">
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
<SortableHeader label="ID" active={sortKey === "categoryId"} order={sortOrder} onClick={() => toggleSort("categoryId")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||||
</TableHead>
|
</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")} />
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -371,7 +366,7 @@ function SortableHeader({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 hover:text-indigo-900"
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
||||||
>
|
>
|
||||||
@@ -383,7 +378,7 @@ function SortableHeader({
|
|||||||
<ArrowDown className="size-3.5" />
|
<ArrowDown className="size-3.5" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="size-3.5 text-indigo-700/40" />
|
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -163,7 +163,7 @@ function SelectItem({
|
|||||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
<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.ItemIndicator>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ function Sparkline({ points }: { points: number[] }) {
|
|||||||
className="h-4.5 w-12 shrink-0 overflow-visible"
|
className="h-4.5 w-12 shrink-0 overflow-visible"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
<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-indigo-600" stroke="white" strokeWidth={1.5} />
|
<circle cx={lastX} cy={lastY} r={2.5} className="fill-primary stroke-card" strokeWidth={1.5} />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,21 +71,21 @@ export function StatCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Icon className="size-5 text-indigo-600" />
|
<Icon className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-2">
|
<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} />}
|
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function StatCard({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold",
|
"font-semibold",
|
||||||
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
|
isGood ? "text-success" : "text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPositive ? "+" : "-"}
|
{isPositive ? "+" : "-"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|||||||
return (
|
return (
|
||||||
<thead
|
<thead
|
||||||
data-slot="table-header"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
`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
|
## 6. Reference Data
|
||||||
|
|||||||
Reference in New Issue
Block a user