feat(dashboard): add date filter and stat groups components

- Implemented DashboardDateFilter component for selecting date ranges.
- Created DashboardStatGroups component to display various dashboard statistics.
- Added dashboard chart data processing logic to support new chart specifications.
- Introduced reusable Chart components for rendering different types of charts.
This commit is contained in:
2026-10-21 14:57:54 +05:30
parent e6efc33ac0
commit 0a212ed1aa
16 changed files with 1129 additions and 350 deletions
@@ -157,6 +157,14 @@ public class PermissionCoverageTests
[InlineData("hrm.payroll")] [InlineData("hrm.payroll")]
[InlineData("sales-returns")] [InlineData("sales-returns")]
[InlineData("settings.audit-logs")] [InlineData("settings.audit-logs")]
[InlineData("dashboard.stock")]
[InlineData("dashboard.procurement")]
[InlineData("dashboard.approvals")]
[InlineData("dashboard.sales")]
[InlineData("dashboard.accounts")]
[InlineData("dashboard.hrm")]
[InlineData("dashboard.production")]
[InlineData("dashboard.service")]
public void NewPermissions_AreSeeded(string code) public void NewPermissions_AreSeeded(string code)
{ {
using var db = CreateContext(); using var db = CreateContext();
@@ -12,12 +12,25 @@ namespace ERPCore.Controllers;
public sealed class DashboardController : ApiControllerBase public sealed class DashboardController : ApiControllerBase
{ {
private readonly IDashboardService _dashboard; private readonly IDashboardService _dashboard;
private readonly IDashboardChartsService _charts;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard; public DashboardController(IDashboardService dashboard, IDashboardChartsService charts)
{
_dashboard = dashboard;
_charts = charts;
}
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary> /// <summary>Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR.</summary>
[HttpGet("stats")] [HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct) public async Task<ActionResult<DashboardStatsDto>> GetStats(
=> Ok(await _dashboard.GetStatsAsync(branchId, ct)); [FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, from, to, ct));
/// <summary>Datasets for the dashboard graphs; the time-based ones follow the optional from / to period.</summary>
[HttpGet("charts")]
[ProducesResponseType(typeof(DashboardChartsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardChartsDto>> GetCharts(
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _charts.GetChartsAsync(branchId, from, to, ct));
} }
@@ -10,6 +10,9 @@ namespace ERPCore.Dtos.Dashboard;
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no /// 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 /// aggregation needed. A single computed-on-read object, not a stored entity — same as
/// reorder alerts (docs/11 §5.7). /// reorder alerts (docs/11 §5.7).
/// Most counts are a snapshot of what is open right now. The few that describe activity
/// (sales returns, bundle sales, bank deposits, employees present) are counted inside the
/// optional From-To period the dashboard filter sends (default: today).
/// </summary> /// </summary>
public sealed record DashboardStatsDto( public sealed record DashboardStatsDto(
int LowStockAlerts, int LowStockAlerts,
@@ -24,7 +27,37 @@ public sealed record DashboardStatsDto(
int OpenRfqs, int OpenRfqs,
int PendingLeaveRequests, int PendingLeaveRequests,
int OverdueSalesInvoices, int OverdueSalesInvoices,
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales); IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales,
// Stock & warehouse
int BatchesExpiringSoon,
int TransfersInTransit,
int DraftStockAdjustments,
int WastagePendingApproval,
// Approvals
int PendingApprovals,
int PriceAdjustmentsPending,
// Sales (SalesReturns and BundleSales are counted inside the From-To period)
int DraftSalesInvoices,
int DraftSalesSlips,
int SalesReturns,
int BundleSales,
// Purchasing
int PurchaseReturnsPending,
int UnpaidPurchaseInvoices,
// Money
decimal CashInHand,
decimal BankDeposits,
// Service & warranty
int OpenSupportTickets,
int WarrantiesExpiringSoon,
int WarrantyClaimsOpen,
// Production
int ProductionRunsInProgress,
// HR
int PayrollRunsPending,
int AttendanceBatchesPending,
int EmployeesPresent,
int TotalActiveEmployees);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary> /// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
@@ -103,7 +103,15 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 }, new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 },
new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 }, new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 },
// SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles. // SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles.
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 } new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 },
new Permission { PermissionId = 72, Code = "NAV:dashboard.stock", SubNavItemId = 59 },
new Permission { PermissionId = 73, Code = "NAV:dashboard.procurement", SubNavItemId = 60 },
new Permission { PermissionId = 74, Code = "NAV:dashboard.approvals", SubNavItemId = 61 },
new Permission { PermissionId = 75, Code = "NAV:dashboard.sales", SubNavItemId = 62 },
new Permission { PermissionId = 76, Code = "NAV:dashboard.accounts", SubNavItemId = 63 },
new Permission { PermissionId = 77, Code = "NAV:dashboard.hrm", SubNavItemId = 64 },
new Permission { PermissionId = 78, Code = "NAV:dashboard.production", SubNavItemId = 65 },
new Permission { PermissionId = 79, Code = "NAV:dashboard.service", SubNavItemId = 66 }
); );
} }
} }
@@ -103,7 +103,17 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 }, new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 },
new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 }, new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 },
// Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large. // Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large.
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 } new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 },
// Dashboard (NavItemId 1) children - one per area. Each switches that area's stat cards and graphs on the
// dashboard (they are not pages, so Href is the dashboard itself).
new SubNavItem { SubNavItemId = 59, NavItemId = 1, Code = "dashboard.stock", Label = "Stock", Href = "/dashboard", SortOrder = 1 },
new SubNavItem { SubNavItemId = 60, NavItemId = 1, Code = "dashboard.procurement", Label = "Procurement", Href = "/dashboard", SortOrder = 2 },
new SubNavItem { SubNavItemId = 61, NavItemId = 1, Code = "dashboard.approvals", Label = "Approvals", Href = "/dashboard", SortOrder = 3 },
new SubNavItem { SubNavItemId = 62, NavItemId = 1, Code = "dashboard.sales", Label = "Sales", Href = "/dashboard", SortOrder = 4 },
new SubNavItem { SubNavItemId = 63, NavItemId = 1, Code = "dashboard.accounts", Label = "Accounts", Href = "/dashboard", SortOrder = 5 },
new SubNavItem { SubNavItemId = 64, NavItemId = 1, Code = "dashboard.hrm", Label = "Human Resources", Href = "/dashboard", SortOrder = 6 },
new SubNavItem { SubNavItemId = 65, NavItemId = 1, Code = "dashboard.production", Label = "Production", Href = "/dashboard", SortOrder = 7 },
new SubNavItem { SubNavItemId = 66, NavItemId = 1, Code = "dashboard.service", Label = "Service & Warranty", Href = "/dashboard", SortOrder = 8 }
); );
} }
} }
@@ -4633,6 +4633,54 @@ namespace ERPCore.Migrations
PermissionId = 71, PermissionId = 71,
Code = "NAV:settings.notification-config", Code = "NAV:settings.notification-config",
SubNavItemId = 24 SubNavItemId = 24
},
new
{
PermissionId = 72,
Code = "NAV:dashboard.stock",
SubNavItemId = 59
},
new
{
PermissionId = 73,
Code = "NAV:dashboard.procurement",
SubNavItemId = 60
},
new
{
PermissionId = 74,
Code = "NAV:dashboard.approvals",
SubNavItemId = 61
},
new
{
PermissionId = 75,
Code = "NAV:dashboard.sales",
SubNavItemId = 62
},
new
{
PermissionId = 76,
Code = "NAV:dashboard.accounts",
SubNavItemId = 63
},
new
{
PermissionId = 77,
Code = "NAV:dashboard.hrm",
SubNavItemId = 64
},
new
{
PermissionId = 78,
Code = "NAV:dashboard.production",
SubNavItemId = 65
},
new
{
PermissionId = 79,
Code = "NAV:dashboard.service",
SubNavItemId = 66
}); });
}); });
@@ -8276,6 +8324,86 @@ namespace ERPCore.Migrations
NavItemId = 9, NavItemId = 9,
SortOrder = 5, SortOrder = 5,
Status = "Active" Status = "Active"
},
new
{
SubNavItemId = 59,
Code = "dashboard.stock",
Href = "/dashboard",
Label = "Stock",
NavItemId = 1,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 60,
Code = "dashboard.procurement",
Href = "/dashboard",
Label = "Procurement",
NavItemId = 1,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 61,
Code = "dashboard.approvals",
Href = "/dashboard",
Label = "Approvals",
NavItemId = 1,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 62,
Code = "dashboard.sales",
Href = "/dashboard",
Label = "Sales",
NavItemId = 1,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 63,
Code = "dashboard.accounts",
Href = "/dashboard",
Label = "Accounts",
NavItemId = 1,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 64,
Code = "dashboard.hrm",
Href = "/dashboard",
Label = "Human Resources",
NavItemId = 1,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 65,
Code = "dashboard.production",
Href = "/dashboard",
Label = "Production",
NavItemId = 1,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 66,
Code = "dashboard.service",
Href = "/dashboard",
Label = "Service & Warranty",
NavItemId = 1,
SortOrder = 8,
Status = "Active"
}); });
}); });
+1
View File
@@ -231,6 +231,7 @@ builder.Services.AddScoped<IAuditService, AuditService>();
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement) // Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
builder.Services.AddScoped<IDashboardService, DashboardService>(); builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardChartsService, DashboardChartsService>();
// 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>();
+293 -31
View File
@@ -1,10 +1,13 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities; using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Dashboard; using ERPCore.Dtos.Dashboard;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.Infra.Auth;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services; namespace ERPCore.Services;
@@ -27,7 +30,32 @@ public sealed class DashboardService : IDashboardService
private readonly IRepository<LeaveRequest> _leaveRequests; private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SalesInvoice> _salesInvoices; private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices; private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IReorderService _reorder; private readonly IServiceScopeFactory _scopes;
private readonly IMemoryCache _cache;
private readonly ICurrentUser _user;
private readonly INavPermissionService _nav;
private readonly ICashInHandService _cash;
private readonly BusinessDayClock _clock;
private readonly IRepository<Batch> _batches;
private readonly IRepository<StockTransfer> _transfers;
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<PriceAdjustment> _priceAdjustments;
private readonly IRepository<SalesSlip> _salesSlips;
private readonly IRepository<SalesReturn> _salesReturns;
private readonly IRepository<BundleSale> _bundleSales;
private readonly IRepository<PurchaseReturn> _purchaseReturns;
private readonly IRepository<BankDeposit> _bankDeposits;
private readonly IRepository<SupportTicket> _tickets;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IRepository<ProductionRun> _productionRuns;
private readonly IRepository<PayrollRun> _payrollRuns;
private readonly IRepository<AttendanceUploadBatch> _attendanceBatches;
private readonly IRepository<AttendanceRecord> _attendance;
private readonly IRepository<Employee> _employees;
// "Soon" for batch expiry and warranty end dates.
private const int ExpiringSoonDays = 30;
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema // Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
// has none), so "overdue" is approximated as a Posted invoice with an outstanding // has none), so "overdue" is approximated as a Posted invoice with an outstanding
@@ -41,7 +69,15 @@ public sealed class DashboardService : IDashboardService
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs, IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices, IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder) IRepository<PurchaseInvoice> purchaseInvoices, IServiceScopeFactory scopes, IMemoryCache cache, ICurrentUser user, INavPermissionService nav,
IRepository<Batch> batches, IRepository<StockTransfer> transfers, IRepository<StockAdjustment> adjustments,
IRepository<ReasonCode> reasonCodes, IRepository<PriceAdjustment> priceAdjustments,
IRepository<SalesSlip> salesSlips, IRepository<SalesReturn> salesReturns, IRepository<BundleSale> bundleSales,
IRepository<PurchaseReturn> purchaseReturns, IRepository<BankDeposit> bankDeposits,
IRepository<SupportTicket> tickets, IRepository<WarrantyRegistration> warranties,
IRepository<ProductionRun> productionRuns, IRepository<PayrollRun> payrollRuns,
IRepository<AttendanceUploadBatch> attendanceBatches, IRepository<AttendanceRecord> attendance,
IRepository<Employee> employees, ICashInHandService cash, BusinessDayClock clock)
{ {
_layers = layers; _layers = layers;
_grns = grns; _grns = grns;
@@ -52,68 +88,294 @@ public sealed class DashboardService : IDashboardService
_leaveRequests = leaveRequests; _leaveRequests = leaveRequests;
_salesInvoices = salesInvoices; _salesInvoices = salesInvoices;
_purchaseInvoices = purchaseInvoices; _purchaseInvoices = purchaseInvoices;
_reorder = reorder; _scopes = scopes;
_cache = cache;
_user = user;
_nav = nav;
_cash = cash;
_clock = clock;
_batches = batches;
_transfers = transfers;
_adjustments = adjustments;
_reasonCodes = reasonCodes;
_priceAdjustments = priceAdjustments;
_salesSlips = salesSlips;
_salesReturns = salesReturns;
_bundleSales = bundleSales;
_purchaseReturns = purchaseReturns;
_bankDeposits = bankDeposits;
_tickets = tickets;
_warranties = warranties;
_productionRuns = productionRuns;
_payrollRuns = payrollRuns;
_attendanceBatches = attendanceBatches;
_attendance = attendance;
_employees = employees;
} }
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default) // The dashboard is opened often and every card needs a query, so a short cache makes repeat loads and
// filter flips instant. Keyed by user as well as the requested branch / period: services narrow the branch
// per caller, so two users must never share an entry. 30 seconds keeps the numbers effectively live.
private static readonly TimeSpan StatsCacheTtl = TimeSpan.FromSeconds(30);
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{ {
var key = $"dashboard-stats:{_user.UserId}:{branchId}:{from}:{to}";
if (_cache.TryGetValue(key, out DashboardStatsDto? cached) && cached is not null) return cached;
var stats = await ComputeStatsAsync(branchId, from, to, ct);
_cache.Set(key, stats, StatsCacheTtl);
return stats;
}
/// <summary>
/// Reorder alerts are the slowest single piece (a live availability lookup per item), and they use services
/// that share nothing with the counts below. They run in their own scope - their own DbContext - so they
/// overlap with the sequential counts instead of adding to them.
/// </summary>
/// <summary>Whether the caller's role may reach the screen a card links to; see <see cref="DashboardAccess"/>.</summary>
private Task<bool> CanAsync(string navCode, CancellationToken ct) => _nav.CanAsync(_user, navCode, ct);
private async Task<int> CountLowStockAlertsAsync(int? branchId, CancellationToken ct)
{
using var scope = _scopes.CreateScope();
var reorder = scope.ServiceProvider.GetRequiredService<IReorderService>();
// PageSize:1 is enough - GetAlertsAsync computes the full alert count before paging.
return (await reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)).Pagination.TotalItems;
}
private async Task<DashboardStatsDto> ComputeStatsAsync(int? branchId, DateOnly? from, DateOnly? to, CancellationToken ct)
{
// Each figure belongs to a dashboard area (Settings -> Roles -> Dashboard: stock, procurement, approvals, sales,
// accounts, hrm, production, service). A role without the area's permission gets 0 and the query is not run
// (administrators pass every check) - see DashboardAccess.
var canReorder = await CanAsync("dashboard.stock", ct);
var canEnquiry = await CanAsync("dashboard.stock", ct);
var canValuation = await CanAsync("dashboard.stock", ct);
var canCounts = await CanAsync("dashboard.stock", ct);
var canStock = await CanAsync("dashboard.stock", ct);
var canTransfers = await CanAsync("dashboard.stock", ct);
var canAdjustments = await CanAsync("dashboard.stock", ct);
var canWastage = await CanAsync("dashboard.approvals", ct);
var canReceiving = await CanAsync("dashboard.procurement", ct);
var canProcurement = await CanAsync("dashboard.procurement", ct);
var canPoApprovals = await CanAsync("dashboard.approvals", ct);
var canPurchaseReturns = await CanAsync("dashboard.procurement", ct);
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
var canSales = await CanAsync("dashboard.sales", ct);
var canSalesReturns = await CanAsync("dashboard.sales", ct);
var canApprovals = await CanAsync("dashboard.approvals", ct);
var canPriceAdjustments = await CanAsync("dashboard.approvals", ct);
var canBankDeposits = await CanAsync("dashboard.accounts", ct);
var canProduction = await CanAsync("dashboard.production", ct);
var canLeave = await CanAsync("dashboard.hrm", ct);
var canPayroll = await CanAsync("dashboard.hrm", ct);
var canAttendance = await CanAsync("dashboard.hrm", ct);
var canEmployees = await CanAsync("dashboard.hrm", ct);
var canService = await CanAsync("dashboard.service", ct);
var lowStockTask = canReorder ? CountLowStockAlertsAsync(branchId, ct) : Task.FromResult(0);
// The period only drives the activity counts; everything else is a snapshot of what is open now.
// "Today" and day boundaries follow the business time zone, like the day-end report and cash in hand, so a
// sale rung up just after local midnight lands on the same day everywhere.
var today = _clock.Today;
var periodFrom = from ?? to ?? today;
var periodTo = to ?? periodFrom;
if (periodTo < periodFrom) (periodFrom, periodTo) = (periodTo, periodFrom);
// Timestamps (created / posted times) are compared against the business day's UTC bounds; plain dates
// (attendance days) against midnight-to-midnight.
var fromAt = _clock.ToUtc(periodFrom);
var toBefore = _clock.ToUtc(periodTo.AddDays(1));
var fromDay = periodFrom.ToDateTime(TimeOnly.MinValue);
var toDayEnd = periodTo.AddDays(1).ToDateTime(TimeOnly.MinValue);
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext, // Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
// which cannot run concurrent operations. // which cannot run concurrent operations.
var layers = _layers.Query().AsNoTracking(); var layers = _layers.Query().AsNoTracking();
if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId); if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; var onHandTotal = canEnquiry ? await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m : 0m;
var onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct); var onHandWarehouses = canEnquiry ? await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct) : 0;
var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m; var stockValuationTotal = canValuation ? await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m : 0m;
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy // EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
// Select — project to an anonymous type first, then materialize into the record. // Select — project to an anonymous type first, then materialize into the record.
var stockValuationByWarehouseRaw = await layers var stockValuationByWarehouse = new List<WarehouseValuationDto>();
.GroupBy(l => l.WarehouseId) if (canValuation)
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) {
.OrderByDescending(w => w.Total) var stockValuationByWarehouseRaw = await layers
.ToListAsync(ct); .GroupBy(l => l.WarehouseId)
var stockValuationByWarehouse = stockValuationByWarehouseRaw .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) .OrderByDescending(w => w.Total)
.ToList(); .ToListAsync(ct);
stockValuationByWarehouse = stockValuationByWarehouseRaw
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
.ToList();
}
var grns = _grns.Query().AsNoTracking(); var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId); if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct); var pendingGrns = canReceiving ? await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct) : 0;
var counts = _counts.Query().AsNoTracking(); var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId); if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct); var pendingCounts = canCounts ? await counts.CountAsync(c => c.Status == CountStatus.Counted, ct) : 0;
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet // PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
// (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column, // (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column,
// not yet added) — these three stay global regardless of the selected branch. // not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking() var pendingApprovalPOs = canPoApprovals
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); ? await _pos.Query().AsNoTracking().CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct)
var openRequisitions = await _requisitions.Query().AsNoTracking() : 0;
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); var openRequisitions = canProcurement
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); ? await _requisitions.Query().AsNoTracking().CountAsync(r => r.Status == RequisitionStatus.Submitted, ct)
: 0;
var openRfqs = canProcurement ? await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct) : 0;
// LeaveRequest carries no BranchId (like PO/Requisition/RFQ) — global regardless // LeaveRequest carries no BranchId (like PO/Requisition/RFQ) — global regardless
// of the selected branch. // of the selected branch.
var pendingLeaveRequests = await _leaveRequests.Query().AsNoTracking() var pendingLeaveRequests = canLeave
.CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct); ? await _leaveRequests.Query().AsNoTracking().CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct)
: 0;
var overdueSince = DateTime.UtcNow.AddDays(-OverdueAgeDays); var overdueSince = DateTime.UtcNow.AddDays(-OverdueAgeDays);
var salesInvoices = _salesInvoices.Query().AsNoTracking(); var salesInvoices = _salesInvoices.Query().AsNoTracking();
if (branchId is not null) salesInvoices = salesInvoices.Where(i => i.BranchId == branchId); if (branchId is not null) salesInvoices = salesInvoices.Where(i => i.BranchId == branchId);
var overdueSalesInvoices = await salesInvoices.CountAsync( var overdueSalesInvoices = canSalesInvoices
i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct); ? await salesInvoices.CountAsync(i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct)
: 0;
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging. var lowStockAlerts = await lowStockTask;
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
.Pagination.TotalItems;
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct); // The purchases-vs-sales chart puts both modules side by side, so it needs both.
var monthlyPurchasesVsSales = canSalesInvoices && canProcurement
? await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct)
: new List<MonthlyPurchasesVsSalesDto>();
// ---- Stock & warehouse
var soon = today.AddDays(ExpiringSoonDays);
var batchesExpiringSoon = canStock
? await _batches.Query().AsNoTracking().CountAsync(b => b.ExpiryDate != null && b.ExpiryDate >= today && b.ExpiryDate <= soon, ct)
: 0;
var transfers = _transfers.Query().AsNoTracking();
if (branchId is not null) transfers = transfers.Where(t => t.BranchId == branchId);
var transfersInTransit = canTransfers ? await transfers.CountAsync(t => t.Status == TransferStatus.InTransit, ct) : 0;
var adjustments = _adjustments.Query().AsNoTracking();
if (branchId is not null) adjustments = adjustments.Where(a => a.BranchId == branchId);
var draftStockAdjustments = canAdjustments ? await adjustments.CountAsync(a => a.Status == AdjustmentStatus.Draft, ct) : 0;
var awaitingApproval = adjustments.Where(a =>
a.Status == AdjustmentStatus.PendingApproval || a.Status == AdjustmentStatus.PendingSecondApproval);
var stockAdjustmentsAwaiting = canApprovals ? await awaitingApproval.CountAsync(ct) : 0;
// Wastage = an adjustment with a damage / theft / expiry reason code (same rule AdjustmentService uses).
var wastageCodes = WastageReasonCodes.Codes.Select(c => c.ToUpper()).ToList();
var wastageReasonIds = _reasonCodes.Query().Where(r => wastageCodes.Contains(r.Code.ToUpper())).Select(r => r.ReasonCodeId);
var wastagePendingApproval = canWastage ? await awaitingApproval.CountAsync(a => wastageReasonIds.Contains(a.ReasonCodeId), ct) : 0;
// ---- Approvals: every document waiting on an approver (stock adjustments + price adjustments).
// Purchase orders have their own card, so they are not counted here. The price-adjustment count feeds both the
// approval queue card and its own card, so it is read when either is allowed and handed out per card below.
var priceAdjustmentsRaw = canApprovals || canPriceAdjustments
? await _priceAdjustments.Query().AsNoTracking().CountAsync(
p => p.Status == PriceAdjustmentStatus.PendingApproval || p.Status == PriceAdjustmentStatus.PendingSecondApproval, ct)
: 0;
var priceAdjustmentsPending = canPriceAdjustments ? priceAdjustmentsRaw : 0;
var pendingApprovals = canApprovals ? stockAdjustmentsAwaiting + priceAdjustmentsRaw : 0;
// ---- Sales
var draftSalesInvoices = canSalesInvoices ? await salesInvoices.CountAsync(i => i.Status == SalesInvoiceStatus.Draft, ct) : 0;
// Slips, returns and bundle sales carry a warehouse rather than a branch, so the branch comes via the warehouse.
var slips = _salesSlips.Query().AsNoTracking();
if (branchId is not null) slips = slips.Where(x => x.Warehouse!.BranchId == branchId);
var draftSalesSlips = canSalesInvoices ? await slips.CountAsync(x => x.Status == SalesSlipStatus.Draft, ct) : 0;
var salesReturns = _salesReturns.Query().AsNoTracking();
if (branchId is not null) salesReturns = salesReturns.Where(x => x.Warehouse!.BranchId == branchId);
var salesReturnsInPeriod = canSalesReturns
? await salesReturns.CountAsync(x => x.Status == ReturnStatus.Posted && x.CreatedAt >= fromAt && x.CreatedAt < toBefore, ct)
: 0;
var bundleSales = _bundleSales.Query().AsNoTracking();
if (branchId is not null) bundleSales = bundleSales.Where(x => x.Warehouse!.BranchId == branchId);
var bundleSalesInPeriod = canSales
? await bundleSales.CountAsync(x => x.Status == BundleSaleStatus.Posted && x.BundleDate >= fromAt && x.BundleDate < toBefore, ct)
: 0;
// ---- Purchasing
var purchaseReturns = _purchaseReturns.Query().AsNoTracking();
if (branchId is not null) purchaseReturns = purchaseReturns.Where(x => x.Warehouse!.BranchId == branchId);
var purchaseReturnsPending = canPurchaseReturns ? await purchaseReturns.CountAsync(x => x.Status == ReturnStatus.Draft, ct) : 0;
var unpaidPurchases = _purchaseInvoices.Query().AsNoTracking();
if (branchId is not null) unpaidPurchases = unpaidPurchases.Where(i => i.BranchId == branchId);
var unpaidPurchaseInvoices = canProcurement
? await unpaidPurchases.CountAsync(i => i.Status == PurchaseInvoiceStatus.Posted && i.BalanceAmount > 0, ct)
: 0;
// ---- Money: cash still in hand as of the end of the period (the same closing figure the day-end report shows),
// and the value of bank deposits made inside the period.
var cashInHand = canBankDeposits ? (await _cash.GetPositionAsync(periodTo, branchId, ct)).Position.Closing : 0m;
var deposits = _bankDeposits.Query().AsNoTracking();
if (branchId is not null) deposits = deposits.Where(d => d.BranchId == branchId);
var bankDeposits = canBankDeposits
? await deposits
.Where(d => d.Status == BankDepositStatus.Posted && d.DepositDate >= periodFrom && d.DepositDate <= periodTo)
.SumAsync(d => (decimal?)d.Amount, ct) ?? 0m
: 0m;
// ---- Service & warranty (tickets and warranties have no branch)
var openSupportTickets = canService
? await _tickets.Query().AsNoTracking().CountAsync(t => t.Status == TicketStatus.Open || t.Status == TicketStatus.InProgress, ct)
: 0;
var warrantiesExpiringSoon = canService
? await _warranties.Query().AsNoTracking().CountAsync(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= soon, ct)
: 0;
var warrantyClaimsOpen = canService
? await _warranties.Query().AsNoTracking().CountAsync(w => w.Status == WarrantyStatus.Claimed, ct)
: 0;
// ---- Production
var productionRuns = _productionRuns.Query().AsNoTracking();
if (branchId is not null) productionRuns = productionRuns.Where(r => r.Warehouse!.BranchId == branchId);
var productionRunsInProgress = canProduction ? await productionRuns.CountAsync(r => r.Status == ProductionRunStatus.InProgress, ct) : 0;
// ---- HR
var payrollRuns = _payrollRuns.Query().AsNoTracking();
if (branchId is not null) payrollRuns = payrollRuns.Where(r => r.BranchId == branchId);
var payrollRunsPending = canPayroll ? await payrollRuns.CountAsync(r => r.Status == PayrollRunStatus.Draft, ct) : 0;
// Attendance upload batches carry no branch - global.
var attendanceBatchesPending = canAttendance
? await _attendanceBatches.Query().AsNoTracking().CountAsync(
b => b.Status == AttendanceBatchStatus.Draft || b.Status == AttendanceBatchStatus.Validated, ct)
: 0;
var attendance = _attendance.Query().AsNoTracking();
if (branchId is not null) attendance = attendance.Where(a => a.Employee!.BranchId == branchId);
var employeesPresent = canAttendance
? await attendance
.Where(a => a.AttendanceStatus == AttendanceStatus.Present && a.AttendanceDate >= fromDay && a.AttendanceDate < toDayEnd)
.Select(a => a.EmployeeId).Distinct().CountAsync(ct)
: 0;
var employees = _employees.Query().AsNoTracking();
if (branchId is not null) employees = employees.Where(e => e.BranchId == branchId);
var totalActiveEmployees = canEmployees ? await employees.CountAsync(e => e.Status == EmployeeStatus.Active, ct) : 0;
return new DashboardStatsDto( return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs, pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales); pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales,
batchesExpiringSoon, transfersInTransit, draftStockAdjustments, wastagePendingApproval,
pendingApprovals, priceAdjustmentsPending,
draftSalesInvoices, draftSalesSlips, salesReturnsInPeriod, bundleSalesInPeriod,
purchaseReturnsPending, unpaidPurchaseInvoices,
cashInHand, bankDeposits,
openSupportTickets, warrantiesExpiringSoon, warrantyClaimsOpen,
productionRunsInProgress,
payrollRunsPending, attendanceBatchesPending, employeesPresent, totalActiveEmployees);
} }
/// <summary> /// <summary>
@@ -5,5 +5,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary> /// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
public interface IDashboardService public interface IDashboardService
{ {
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default); /// <param name="from">Start of the period for the activity counts (default: today).</param>
/// <param name="to">End of the period, inclusive (default: <paramref name="from"/>, else today).</param>
Task<DashboardStatsDto> GetStatsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
} }
+183 -292
View File
@@ -2,74 +2,53 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { import { ArrowDownLeft, ArrowUpRight, ScrollText } from "lucide-react"
AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign,
Boxes,
ClipboardList,
ListChecks,
PackageCheck,
ScrollText,
Send,
} from "lucide-react"
import { dashboardApi } from "@/lib/api/dashboard" import { dashboardApi } from "@/lib/api/dashboard"
import { stockApi } from "@/lib/api/stock" import { stockApi } from "@/lib/api/stock"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { DashboardStats } from "@/types/dashboard" import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
import { LedgerEntry } from "@/types/stock" import { LedgerEntry } from "@/types/stock"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useIsMobile } from "@/hooks/use-mobile" import { DashboardDateFilter, DashboardPeriodValue, periodBounds } from "@/components/dashboard/DashboardDateFilter"
import { StatCard } from "@/components/ui/stat-card" import { DashboardCharts } from "@/components/dashboard/DashboardCharts"
import { DashboardStatGroups } from "@/components/dashboard/DashboardStatGroups"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart"
const TREND_DAYS = 14
const MOBILE_TREND_DAYS = 5
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5
function isoDate(d: Date) {
return d.toISOString().slice(0, 10)
}
export default function DashboardPage() { export default function DashboardPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled, can } = useAuth()
// The recent-movements table belongs to the Stock area of the dashboard (Settings -> Roles -> Dashboard).
const canStock = can("dashboard.stock")
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null)
const [charts, setCharts] = useState<DashboardChartsData | null>(null)
const [chartsFailed, setChartsFailed] = useState(false)
const [movements, setMovements] = useState<LedgerEntry[] | null>(null) const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [period, setPeriod] = useState<DashboardPeriodValue>({ period: "today" })
const bounds = useMemo(() => periodBounds(period), [period])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const from = new Date()
from.setDate(from.getDate() - (TREND_DAYS - 1))
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([ Promise.all([
dashboardApi.stats({ branchId }), canStock ? stockApi.ledger({ page: 1, pageSize: 5, branchId }) : Promise.resolve(null),
stockApi.ledger({ page: 1, pageSize: 5, branchId }), // Only used to label warehouses; a role without the Warehouses screen just sees ids instead of an error.
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }), warehousesApi.list().catch(() => null),
warehousesApi.list(),
]) ])
.then(([statsRes, ledger, trendRes, warehousesRes]) => { .then(([ledger, warehousesRes]) => {
if (cancelled) return if (cancelled) return
setStats(statsRes) setMovements(ledger?.items ?? [])
setMovements(ledger.items) setWarehouses(warehousesRes?.items ?? [])
setTrend(trendRes.items)
setWarehouses(warehousesRes.items)
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setError(errorMessage(err)) if (!cancelled) setError(errorMessage(err))
@@ -78,61 +57,62 @@ export default function DashboardPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [activeBranchId, branchingEnabled]) }, [activeBranchId, branchingEnabled, canStock])
// Stats follow the date filter: the activity counts use its from–to period, the rest are snapshots.
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId, from: bounds.from, to: bounds.to })
.then((res) => {
if (!cancelled) setStats(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
// Graph datasets follow the same date filter. A failure only blanks the graphs, not the whole dashboard.
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.charts({ branchId, from: bounds.from, to: bounds.to })
.then((res) => {
if (cancelled) return
setCharts(res)
setChartsFailed(false)
})
.catch(() => {
if (!cancelled) setChartsFailed(true)
})
return () => {
cancelled = true
}
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) 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 warehouseCodes = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w.code])), [warehouses])
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" })) const loaded = stats && movements && warehouses
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])
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile()
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
const shownTrend = useMemo(
() => ({
labels: movementTrend.labels.slice(-trendDays),
inData: movementTrend.inData.slice(-trendDays),
outData: movementTrend.outData.slice(-trendDays),
}),
[movementTrend, trendDays]
)
const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
const loaded = stats && movements && trend && warehouses
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1> <div className="shrink-0">
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p> <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>
<DashboardDateFilter value={period} onChange={setPeriod} />
</div> </div>
{error && ( {error && (
@@ -141,232 +121,143 @@ export default function DashboardPage() {
</div> </div>
)} )}
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the <DashboardStatGroups from={bounds.from} to={bounds.to} stats={stats} />
compact stacked layout, so they would not match the stat cards on every other screen. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
</Link>
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
</Link>
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
</Link>
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
</Link>
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
</Link>
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
</Link>
</>
) : (
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)}
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <DashboardCharts stats={stats} charts={charts} warehouseCodes={warehouseCodes} error={chartsFailed} />
{canStock && (
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> <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"> <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"> <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" /> <ScrollText className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse Recent Stock Movements
</h2> </h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}> <Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details View all
</Link> </Link>
</div> </div>
{loaded ? ( {loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? ( movements.length > 0 ? (
<div className="h-64 sm:h-72"> <>
<BarChart {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
labels={[ <div className="flex flex-col gap-3 md:hidden">
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`), {movements.map((entry) => (
...Array<string>(valuationPad).fill(""), <div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
]} <div className="flex items-start justify-between gap-2">
datasets={[ <div className="flex min-w-0 items-center gap-3">
{ <div
label: "Stock Value (LKR)", className={cn(
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)], "flex size-9 shrink-0 items-center justify-center rounded-lg",
backgroundColor: "#E11D48", entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
}, )}
]} >
barPercentage={0.6} {entry.direction === "In" ? (
/> <ArrowDownLeft className="size-4 text-success" />
</div> ) : (
) : ( <ArrowUpRight className="size-4 text-destructive" />
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p> )}
) </div>
) : ( <div className="min-w-0">
!error && <Skeleton className="h-64 rounded-lg sm:h-72" /> <p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
)} <p className="text-sm text-muted-foreground">
</div> {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> </div>
<div className="mb-4 flex items-center gap-2"> </div>
<ScrollText className="size-4 shrink-0 text-primary" /> <Badge
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base"> variant="outline"
Stock Movement Trend (last {trendDays} days)
</h2>
</div>
{loaded ? (
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
<div className="h-64 sm:h-72">
<LineChart
labels={shownTrend.labels}
datasets={[
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div>
</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 ? (
<>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
{movements.map((entry) => (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn( className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg", "h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10" entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)} )}
> >
{entry.direction === "In" ? ( {entry.direction}
<ArrowDownLeft className="size-4 text-success" /> </Badge>
) : ( </div>
<ArrowUpRight className="size-4 text-destructive" />
)} <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div> </div>
<div className="min-w-0"> <div>
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p> <p className="text-muted-foreground">Date</p>
<p className="text-sm text-muted-foreground"> <p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} </div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p> </p>
</div> </div>
</div> </div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</div> </div>
))}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm"> {/* Desktop: table. */}
<div> <div className="hidden overflow-hidden rounded-xl border md:block">
<p className="text-muted-foreground">Qty</p> <div className="overflow-x-auto">
<p className="font-medium text-foreground">{entry.qtyBase}</p> <Table className="min-w-160">
</div> <TableHeader>
<div> <TableRow>
<p className="text-muted-foreground">Date</p> <TableHead>Item</TableHead>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p> <TableHead>Warehouse</TableHead>
</div> <TableHead>Direction</TableHead>
<div className="col-span-2"> <TableHead>Qty</TableHead>
<p className="text-muted-foreground">Source</p> <TableHead>Source</TableHead>
<p className="font-medium text-foreground"> <TableHead className="text-right">Date</TableHead>
{entry.sourceDocType} #{entry.sourceDocId} </TableRow>
</p> </TableHeader>
</div> <TableBody>
</div> {movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{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>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div>
</>
) : (
<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>
)
{/* Desktop: table. */} )}
<div className="hidden overflow-hidden rounded-xl border md:block"> </div>
<div className="overflow-x-auto"> )}
<Table className="min-w-160">
<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>
{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>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
) : (
<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>
) )
} }
+3
View File
@@ -81,6 +81,8 @@
--chart-3: oklch(0.582 0.183 142.495); --chart-3: oklch(0.582 0.183 142.495);
--chart-4: oklch(0.787 0.141 72.031); --chart-4: oklch(0.787 0.141 72.031);
--chart-5: oklch(0.577 0.245 27.325); --chart-5: oklch(0.577 0.245 27.325);
/* Dashboard charts: one slate ink, tinted lighter for extra series, so every graph matches. */
--chart-ink: #5b6b6e;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: oklch(0.98 0 0); --sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.15 0 0); --sidebar-foreground: oklch(0.15 0 0);
@@ -120,6 +122,7 @@
--chart-3: oklch(0.65 0.15 142.495); --chart-3: oklch(0.65 0.15 142.495);
--chart-4: oklch(0.85 0.12 72.031); --chart-4: oklch(0.85 0.12 72.031);
--chart-5: oklch(0.704 0.191 22.216); --chart-5: oklch(0.704 0.191 22.216);
--chart-ink: #a3b3b6;
--sidebar: oklch(0.125 0 0); --sidebar: oklch(0.125 0 0);
--sidebar-foreground: oklch(0.95 0 0); --sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.645 0.215 16.439); --sidebar-primary: oklch(0.645 0.215 16.439);
+18 -16
View File
@@ -58,10 +58,8 @@ export interface StatCardProps {
valueClassName?: string valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */ /** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */ /** Tighter padding and a smaller icon / text, for dense rows such as the dashboard. */
onClick?: () => void compact?: boolean
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
} }
/** /**
@@ -80,8 +78,7 @@ export function StatCard({
className, className,
valueClassName, valueClassName,
iconClassName, iconClassName,
onClick, compact = false,
active = false,
}: StatCardProps) { }: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0 const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0 const isPositive = hasDelta && delta > 0
@@ -98,31 +95,36 @@ export function StatCard({
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" } ? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})} : {})}
className={cn( className={cn(
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30", "@container min-w-0 rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", compact ? "px-3 py-2.5" : "px-4 py-5",
className, className
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
)} )}
> >
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the {/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
value keeps the full width instead of being cut off. */} value keeps the full width instead of being cut off. */}
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5"> <div
className={cn(
"flex",
// Compact cards keep the icon beside the text at every width; the regular card stacks it above when narrow.
compact ? "flex-row items-center gap-2.5" : "flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5"
)}
>
{Icon && ( {Icon && (
<div <div
className={cn( className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11", "flex shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary",
compact ? "size-9" : "size-9 @[12.5rem]:size-11",
iconClassName iconClassName
)} )}
> >
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden /> <Icon className={compact ? "size-4" : "size-4.5 @[12.5rem]:size-5"} aria-hidden />
</div> </div>
)} )}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm text-muted-foreground">{label}</p> <p className={cn("text-muted-foreground", compact ? "text-xs leading-tight" : "truncate text-sm")}>{label}</p>
<div className="flex items-end justify-between gap-2"> <div className="flex items-end justify-between gap-2">
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}> <p className={cn("leading-tight font-semibold tracking-tight wrap-break-word text-foreground", compact ? "text-base" : "text-base @[12.5rem]:text-lg", valueClassName)}>
{formatValue(value)} {formatValue(value)}
</p> </p>
{trend && trend.length > 1 && <Sparkline points={trend} />} {trend && trend.length > 1 && <Sparkline points={trend} />}
+8 -2
View File
@@ -1,9 +1,15 @@
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs). // Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
import { apiRequest, buildQuery } from "@/lib/api-client" import { apiRequest, buildQuery } from "@/lib/api-client"
import { DashboardStats } from "@/types/dashboard" import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
export const dashboardApi = { export const dashboardApi = {
stats(params: { branchId?: number } = {}): Promise<DashboardStats> { /** `from` / `to` (YYYY-MM-DD) set the period for the activity counts; omitted means today. */
stats(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardStats> {
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`) return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
}, },
/** Datasets for the dashboard graphs; the time-based ones follow `from` / `to` (YYYY-MM-DD, default today). */
charts(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardChartsData> {
return apiRequest<DashboardChartsData>(`/dashboard/charts${buildQuery(params)}`)
},
} }
+331 -1
View File
@@ -16,6 +16,7 @@
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cn": "^0.5.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -28,6 +29,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-loading-skeleton": "^3.5.0", "react-loading-skeleton": "^3.5.0",
"recharts": "^3.8.0",
"shadcn": "^4.13.0", "shadcn": "^4.13.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -1909,6 +1911,42 @@
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.3.tgz",
"integrity": "sha512-FbR9W6jf7+tvOCuDmOLJy3yStLCvoRDODRARzfeaWmn75+1fW4hFbMv5X5cWgEkxgWcC/eC5Nva00ZV1iv+0EA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1934,6 +1972,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2398,6 +2442,12 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -2413,6 +2463,12 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2422,12 +2478,48 @@
"@types/d3-color": "*" "@types/d3-color": "*"
} }
}, },
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": { "node_modules/@types/d3-selection": {
"version": "3.0.11", "version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": { "node_modules/@types/d3-transition": {
"version": "3.0.9", "version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -2518,6 +2610,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -3916,6 +4014,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cn": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.5.0.tgz",
"integrity": "sha512-JGEixZEpfFXm1P5uXwvEobOFLlHwIWi5v4tjd1ZwfiYmcEilkuD2l+054rvJcYCR1B+uVThHeUbgmNz1FwuxuA==",
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=22"
}
},
"node_modules/code-block-writer": { "node_modules/code-block-writer": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -4186,6 +4296,18 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -4226,6 +4348,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -4238,6 +4369,31 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": { "node_modules/d3-selection": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -4247,6 +4403,42 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": { "node_modules/d3-timer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -4394,6 +4586,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -4861,6 +5059,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5317,6 +5527,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -6093,6 +6309,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6140,6 +6366,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/iobuffer": { "node_modules/iobuffer": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
@@ -8484,7 +8719,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-loading-skeleton": { "node_modules/react-loading-skeleton": {
@@ -8496,6 +8730,29 @@
"react": ">=16.8.0" "react": ">=16.8.0"
} }
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recast": { "node_modules/recast": {
"version": "0.23.12", "version": "0.23.12",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz",
@@ -8512,6 +8769,57 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10090,6 +10398,28 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -17,6 +17,7 @@
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cn": "^0.5.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -29,6 +30,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-loading-skeleton": "^3.5.0", "react-loading-skeleton": "^3.5.0",
"recharts": "^3.8.0",
"shadcn": "^4.13.0", "shadcn": "^4.13.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
+80
View File
@@ -20,6 +20,36 @@ export interface DashboardStats {
pendingLeaveRequests: number pendingLeaveRequests: number
overdueSalesInvoices: number overdueSalesInvoices: number
monthlyPurchasesVsSales: MonthlyPurchasesVsSales[] monthlyPurchasesVsSales: MonthlyPurchasesVsSales[]
// Stock & warehouse
batchesExpiringSoon: number
transfersInTransit: number
draftStockAdjustments: number
wastagePendingApproval: number
// Approvals
pendingApprovals: number
priceAdjustmentsPending: number
// Sales (salesReturns and bundleSales are counted inside the requested from–to period)
draftSalesInvoices: number
draftSalesSlips: number
salesReturns: number
bundleSales: number
// Purchasing
purchaseReturnsPending: number
unpaidPurchaseInvoices: number
// Money (cashInHand is the closing figure at the end of the period; bankDeposits is summed inside it)
cashInHand: number
bankDeposits: number
// Service & warranty
openSupportTickets: number
warrantiesExpiringSoon: number
warrantyClaimsOpen: number
// Production
productionRunsInProgress: number
// HR (employeesPresent is counted inside the period)
payrollRunsPending: number
attendanceBatchesPending: number
employeesPresent: number
totalActiveEmployees: number
} }
// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM". // One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM".
@@ -29,3 +59,53 @@ export interface MonthlyPurchasesVsSales {
purchases: number purchases: number
sales: number sales: number
} }
// GET /dashboard/charts — every dataset behind the dashboard graphs (Backend/ERPCore/Dtos/Dashboard/DashboardChartsDtos.cs).
// Time-based sets follow the requested from–to period; ageing, batches expiring, low stock and warranties are snapshots.
export interface ChartPoint {
label: string
value: number
}
/** Two values under one label: stock in / out by warehouse, tickets opened / resolved by day. */
export interface ChartPair {
label: string
first: number
second: number
}
export interface DailySalesPoint {
label: string
sales: number
grossProfit: number
expenses: number
}
export interface DocumentStatusPoint {
doc: string
draft: number
posted: number
cancelled: number
}
export interface DashboardChartsData {
dailySales: DailySalesPoint[]
paymentSplit: ChartPoint[]
topItems: ChartPoint[]
salesByCategory: ChartPoint[]
salesByCustomer: ChartPoint[]
documentStatus: DocumentStatusPoint[]
cashInHandByDay: ChartPoint[]
receivablesAgeing: ChartPoint[]
payablesAgeing: ChartPoint[]
expenseBreakdown: ChartPoint[]
topVendors: ChartPoint[]
stockInVsOut: ChartPair[]
batchesExpiring: ChartPoint[]
attendanceMix: ChartPoint[]
payrollCostByMonth: ChartPoint[]
leaveStatus: ChartPoint[]
ticketsOverTime: ChartPair[]
productionStatus: ChartPoint[]
warrantyExpiryByMonth: ChartPoint[]
}