From 0a212ed1aaa861a58921a83c8f08260b1781c4ef Mon Sep 17 00:00:00 2001 From: Naveen Prabath Date: Wed, 21 Oct 2026 14:57:54 +0530 Subject: [PATCH] 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. --- .../ERPCore.Tests/PermissionCoverageTests.cs | 8 + .../Controllers/DashboardController.cs | 21 +- .../ERPCore/Dtos/Dashboard/DashboardDtos.cs | 35 +- .../Configurations/PermissionConfiguration.cs | 10 +- .../Configurations/SubNavItemConfiguration.cs | 12 +- .../Migrations/ErpDbContextModelSnapshot.cs | 128 +++++ Backend/ERPCore/Program.cs | 1 + Backend/ERPCore/Services/DashboardService.cs | 324 ++++++++++-- .../Services/Interfaces/IDashboardService.cs | 4 +- Frontend/erp-system/app/dashboard/page.tsx | 475 +++++++----------- Frontend/erp-system/app/globals.css | 3 + .../erp-system/components/ui/stat-card.tsx | 34 +- Frontend/erp-system/lib/api/dashboard.ts | 10 +- Frontend/erp-system/package-lock.json | 332 +++++++++++- Frontend/erp-system/package.json | 2 + Frontend/erp-system/types/dashboard.ts | 80 +++ 16 files changed, 1129 insertions(+), 350 deletions(-) diff --git a/Backend/ERPCore.Tests/PermissionCoverageTests.cs b/Backend/ERPCore.Tests/PermissionCoverageTests.cs index 0477b6f..1405fcf 100644 --- a/Backend/ERPCore.Tests/PermissionCoverageTests.cs +++ b/Backend/ERPCore.Tests/PermissionCoverageTests.cs @@ -157,6 +157,14 @@ public class PermissionCoverageTests [InlineData("hrm.payroll")] [InlineData("sales-returns")] [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) { using var db = CreateContext(); diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs index a979c63..bf4ddca 100644 --- a/Backend/ERPCore/Controllers/DashboardController.cs +++ b/Backend/ERPCore/Controllers/DashboardController.cs @@ -12,12 +12,25 @@ namespace ERPCore.Controllers; public sealed class DashboardController : ApiControllerBase { private readonly IDashboardService _dashboard; + private readonly IDashboardChartsService _charts; - public DashboardController(IDashboardService dashboard) => _dashboard = dashboard; + public DashboardController(IDashboardService dashboard, IDashboardChartsService charts) + { + _dashboard = dashboard; + _charts = charts; + } - /// Aggregate counts for stock, GRN, and procurement. + /// Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR. [HttpGet("stats")] [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)] - public async Task> GetStats([FromQuery] int? branchId, CancellationToken ct) - => Ok(await _dashboard.GetStatsAsync(branchId, ct)); + public async Task> GetStats( + [FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct) + => Ok(await _dashboard.GetStatsAsync(branchId, from, to, ct)); + + /// Datasets for the dashboard graphs; the time-based ones follow the optional from / to period. + [HttpGet("charts")] + [ProducesResponseType(typeof(DashboardChartsDto), StatusCodes.Status200OK)] + public async Task> GetCharts( + [FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct) + => Ok(await _charts.GetChartsAsync(branchId, from, to, ct)); } diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs index 0b39522..8ab9414 100644 --- a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs +++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs @@ -10,6 +10,9 @@ namespace ERPCore.Dtos.Dashboard; /// 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). +/// 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). /// public sealed record DashboardStatsDto( int LowStockAlerts, @@ -24,7 +27,37 @@ public sealed record DashboardStatsDto( int OpenRfqs, int PendingLeaveRequests, int OverdueSalesInvoices, - IReadOnlyList MonthlyPurchasesVsSales); + IReadOnlyList 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); /// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse. public sealed record WarehouseValuationDto(int WarehouseId, decimal Total); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs index 80e9490..d1c205e 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs @@ -103,7 +103,15 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration(); // Dashboard aggregate stats (cross-domain read: stock, GRN, procurement) builder.Services.AddScoped(); +builder.Services.AddScoped(); // HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents builder.Services.AddSingleton(); diff --git a/Backend/ERPCore/Services/DashboardService.cs b/Backend/ERPCore/Services/DashboardService.cs index 48faf7f..f47129e 100644 --- a/Backend/ERPCore/Services/DashboardService.cs +++ b/Backend/ERPCore/Services/DashboardService.cs @@ -1,10 +1,13 @@ +using ERPCore.Domain; 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 ERPCore.Infra.Auth; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; namespace ERPCore.Services; @@ -27,7 +30,32 @@ public sealed class DashboardService : IDashboardService private readonly IRepository _leaveRequests; private readonly IRepository _salesInvoices; private readonly IRepository _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 _batches; + private readonly IRepository _transfers; + private readonly IRepository _adjustments; + private readonly IRepository _reasonCodes; + private readonly IRepository _priceAdjustments; + private readonly IRepository _salesSlips; + private readonly IRepository _salesReturns; + private readonly IRepository _bundleSales; + private readonly IRepository _purchaseReturns; + private readonly IRepository _bankDeposits; + private readonly IRepository _tickets; + private readonly IRepository _warranties; + private readonly IRepository _productionRuns; + private readonly IRepository _payrollRuns; + private readonly IRepository _attendanceBatches; + private readonly IRepository _attendance; + private readonly IRepository _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 // has none), so "overdue" is approximated as a Posted invoice with an outstanding @@ -41,7 +69,15 @@ public sealed class DashboardService : IDashboardService IRepository layers, IRepository grns, IRepository pos, IRepository requisitions, IRepository counts, IRepository rfqs, IRepository leaveRequests, IRepository salesInvoices, - IRepository purchaseInvoices, IReorderService reorder) + IRepository purchaseInvoices, IServiceScopeFactory scopes, IMemoryCache cache, ICurrentUser user, INavPermissionService nav, + IRepository batches, IRepository transfers, IRepository adjustments, + IRepository reasonCodes, IRepository priceAdjustments, + IRepository salesSlips, IRepository salesReturns, IRepository bundleSales, + IRepository purchaseReturns, IRepository bankDeposits, + IRepository tickets, IRepository warranties, + IRepository productionRuns, IRepository payrollRuns, + IRepository attendanceBatches, IRepository attendance, + IRepository employees, ICashInHandService cash, BusinessDayClock clock) { _layers = layers; _grns = grns; @@ -52,68 +88,294 @@ public sealed class DashboardService : IDashboardService _leaveRequests = leaveRequests; _salesInvoices = salesInvoices; _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 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 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; + } + + /// + /// 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. + /// + /// Whether the caller's role may reach the screen a card links to; see . + private Task CanAsync(string navCode, CancellationToken ct) => _nav.CanAsync(_user, navCode, ct); + + private async Task CountLowStockAlertsAsync(int? branchId, CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var reorder = scope.ServiceProvider.GetRequiredService(); + // 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 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, // which cannot run concurrent operations. var layers = _layers.Query().AsNoTracking(); 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 onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct); - var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m; + var onHandTotal = canEnquiry ? await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m : 0m; + var onHandWarehouses = canEnquiry ? await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct) : 0; + 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 // Select — project to an anonymous type first, then materialize into the record. - var stockValuationByWarehouseRaw = await layers - .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 stockValuationByWarehouse = new List(); + if (canValuation) + { + var stockValuationByWarehouseRaw = await layers + .GroupBy(l => l.WarehouseId) + .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) + .OrderByDescending(w => w.Total) + .ToListAsync(ct); + stockValuationByWarehouse = stockValuationByWarehouseRaw + .Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) + .ToList(); + } var grns = _grns.Query().AsNoTracking(); 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(); 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 // (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. - 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 openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); + var pendingApprovalPOs = canPoApprovals + ? await _pos.Query().AsNoTracking().CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct) + : 0; + var openRequisitions = canProcurement + ? 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 // of the selected branch. - var pendingLeaveRequests = await _leaveRequests.Query().AsNoTracking() - .CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct); + var pendingLeaveRequests = canLeave + ? await _leaveRequests.Query().AsNoTracking().CountAsync(l => l.Status == LeaveRequestStatus.Submitted, ct) + : 0; var overdueSince = DateTime.UtcNow.AddDays(-OverdueAgeDays); var salesInvoices = _salesInvoices.Query().AsNoTracking(); if (branchId is not null) salesInvoices = salesInvoices.Where(i => i.BranchId == branchId); - var overdueSalesInvoices = await salesInvoices.CountAsync( - i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0 && i.InvoiceDate < overdueSince, ct); + var overdueSalesInvoices = canSalesInvoices + ? 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 _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct)) - .Pagination.TotalItems; + var lowStockAlerts = await lowStockTask; - 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(); + + // ---- 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( lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, 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); } /// diff --git a/Backend/ERPCore/Services/Interfaces/IDashboardService.cs b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs index d153a2b..4d57232 100644 --- a/Backend/ERPCore/Services/Interfaces/IDashboardService.cs +++ b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs @@ -5,5 +5,7 @@ namespace ERPCore.Services.Interfaces; /// Cross-domain aggregate stats for the dashboard overview. public interface IDashboardService { - Task GetStatsAsync(int? branchId, CancellationToken ct = default); + /// Start of the period for the activity counts (default: today). + /// End of the period, inclusive (default: , else today). + Task GetStatsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default); } diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 547da52..7214b5e 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -2,74 +2,53 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" -import { - AlertTriangle, - ArrowDownLeft, - ArrowUpRight, - BadgeDollarSign, - Boxes, - ClipboardList, - ListChecks, - PackageCheck, - ScrollText, - Send, -} from "lucide-react" +import { ArrowDownLeft, ArrowUpRight, ScrollText } 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 { DashboardStats } from "@/types/dashboard" +import { DashboardChartsData, DashboardStats } from "@/types/dashboard" import { LedgerEntry } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" -import { useIsMobile } from "@/hooks/use-mobile" -import { StatCard } from "@/components/ui/stat-card" +import { DashboardDateFilter, DashboardPeriodValue, periodBounds } from "@/components/dashboard/DashboardDateFilter" +import { DashboardCharts } from "@/components/dashboard/DashboardCharts" +import { DashboardStatGroups } from "@/components/dashboard/DashboardStatGroups" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" import { buttonVariants } from "@/components/ui/button" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import BarChart from "@/components/ui/bar-chart" -import 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() { - 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(null) + const [charts, setCharts] = useState(null) + const [chartsFailed, setChartsFailed] = useState(false) const [movements, setMovements] = useState(null) - const [trend, setTrend] = useState(null) const [warehouses, setWarehouses] = useState(null) const [error, setError] = useState(null) + const [period, setPeriod] = useState({ period: "today" }) + const bounds = useMemo(() => periodBounds(period), [period]) useEffect(() => { let cancelled = false - const from = new Date() - from.setDate(from.getDate() - (TREND_DAYS - 1)) const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined Promise.all([ - dashboardApi.stats({ branchId }), - stockApi.ledger({ page: 1, pageSize: 5, branchId }), - stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }), - warehousesApi.list(), + canStock ? stockApi.ledger({ page: 1, pageSize: 5, branchId }) : Promise.resolve(null), + // Only used to label warehouses; a role without the Warehouses screen just sees ids instead of an error. + warehousesApi.list().catch(() => null), ]) - .then(([statsRes, ledger, trendRes, warehousesRes]) => { + .then(([ledger, warehousesRes]) => { if (cancelled) return - setStats(statsRes) - setMovements(ledger.items) - setTrend(trendRes.items) - setWarehouses(warehousesRes.items) + setMovements(ledger?.items ?? []) + setWarehouses(warehousesRes?.items ?? []) }) .catch((err) => { if (!cancelled) setError(errorMessage(err)) @@ -78,61 +57,62 @@ export default function DashboardPage() { return () => { 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]) - // 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 warehouseCodes = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w.code])), [warehouses]) - const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" })) - - 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 + const loaded = stats && movements && warehouses return (
-
-

Dashboard

-

Overview of stock, receiving and procurement.

+
+
+

Dashboard

+

Overview of stock, receiving and procurement.

+
+
{error && ( @@ -141,232 +121,143 @@ export default function DashboardPage() {
)} - {/* Three across until there is room for six — at six-up the cards get too narrow and switch to the - compact stacked layout, so they would not match the stat cards on every other screen. */} -
- {loaded ? ( - <> - - - - - - - - - - - - - - - - - - - - ) : ( - !error && Array.from({ length: 6 }).map((_, i) => ) - )} -
+ -
+ + + {canStock && (

- - Stock Valuation by Warehouse + + Recent Stock Movements

- - View details + + View all
{loaded ? ( - (stats.stockValuationByWarehouse ?? []).length > 0 ? ( -
- warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`), - ...Array(valuationPad).fill(""), - ]} - datasets={[ - { - label: "Stock Value (LKR)", - data: [...valuationRows.map((w) => w.total), ...Array(valuationPad).fill(null)], - backgroundColor: "#E11D48", - }, - ]} - barPercentage={0.6} - /> -
- ) : ( -

No stock on hand yet.

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

- Stock Movement Trend (last {trendDays} days) -

-
- - {loaded ? ( - shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? ( -
- -
- ) : ( -

No stock movement in the last {trendDays} days.

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

- - Recent Stock Movements -

- - View all - -
- - {loaded ? ( - movements.length > 0 ? ( - <> - {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */} -
- {movements.map((entry) => ( -
-
-
-
0 ? ( + <> + {/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */} +
+ {movements.map((entry) => ( +
+
+
+
+ {entry.direction === "In" ? ( + + ) : ( + + )} +
+
+

Item #{entry.itemId}

+

+ {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} +

+
+
+ - {entry.direction === "In" ? ( - - ) : ( - - )} + {entry.direction} + +
+ +
+
+

Qty

+

{entry.qtyBase}

-
-

Item #{entry.itemId}

-

- {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} +

+

Date

+

{new Date(entry.createdAt).toLocaleDateString()}

+
+
+

Source

+

+ {entry.sourceDocType} #{entry.sourceDocId}

- - {entry.direction} -
+ ))} +
-
-
-

Qty

-

{entry.qtyBase}

-
-
-

Date

-

{new Date(entry.createdAt).toLocaleDateString()}

-
-
-

Source

-

- {entry.sourceDocType} #{entry.sourceDocId} -

-
-
+ {/* Desktop: table. */} +
+
+ + + + Item + Warehouse + Direction + Qty + Source + Date + + + + {movements.map((entry) => ( + + {entry.itemId} + + {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} + + + + {entry.direction} + + + {entry.qtyBase} + + {entry.sourceDocType} #{entry.sourceDocId} + + + {new Date(entry.createdAt).toLocaleDateString()} + + + ))} + +
+
+ + ) : ( +

No stock movements yet.

+ ) + ) : ( + !error && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + ))}
- - {/* Desktop: table. */} -
-
- - - - Item - Warehouse - Direction - Qty - Source - Date - - - - {movements.map((entry) => ( - - {entry.itemId} - - {warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} - - - - {entry.direction} - - - {entry.qtyBase} - - {entry.sourceDocType} #{entry.sourceDocId} - - - {new Date(entry.createdAt).toLocaleDateString()} - - - ))} - -
-
-
- - ) : ( -

No stock movements yet.

- ) - ) : ( - !error && ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) - )} -
+ ) + )} +
+ )}
) } diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index 76f7e56..47c09c0 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -81,6 +81,8 @@ --chart-3: oklch(0.582 0.183 142.495); --chart-4: oklch(0.787 0.141 72.031); --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; --sidebar: oklch(0.98 0 0); --sidebar-foreground: oklch(0.15 0 0); @@ -120,6 +122,7 @@ --chart-3: oklch(0.65 0.15 142.495); --chart-4: oklch(0.85 0.12 72.031); --chart-5: oklch(0.704 0.191 22.216); + --chart-ink: #a3b3b6; --sidebar: oklch(0.125 0 0); --sidebar-foreground: oklch(0.95 0 0); --sidebar-primary: oklch(0.645 0.215 16.439); diff --git a/Frontend/erp-system/components/ui/stat-card.tsx b/Frontend/erp-system/components/ui/stat-card.tsx index 2b988ce..c9a66c3 100644 --- a/Frontend/erp-system/components/ui/stat-card.tsx +++ b/Frontend/erp-system/components/ui/stat-card.tsx @@ -58,10 +58,8 @@ export interface StatCardProps { valueClassName?: string /** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */ iconClassName?: string - /** Makes the card a toggle button that filters the list below it (click again to clear). */ - onClick?: () => void - /** The card's filter is the one currently applied — highlighted and announced as pressed. */ - active?: boolean + /** Tighter padding and a smaller icon / text, for dense rows such as the dashboard. */ + compact?: boolean } /** @@ -80,8 +78,7 @@ export function StatCard({ className, valueClassName, iconClassName, - onClick, - active = false, + compact = false, }: StatCardProps) { const hasDelta = typeof delta === "number" && 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" } : {})} 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", - onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", - className, - // After className so the "selected" look wins over a card's own tint. - active && "bg-primary/5 ring-2 ring-primary" + "@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", + compact ? "px-3 py-2.5" : "px-4 py-5", + className )} > {/* 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. */} -
+
{Icon && (
- +
)}
-

{label}

+

{label}

-

+

{formatValue(value)}

{trend && trend.length > 1 && } diff --git a/Frontend/erp-system/lib/api/dashboard.ts b/Frontend/erp-system/lib/api/dashboard.ts index c291a0b..527edf1 100644 --- a/Frontend/erp-system/lib/api/dashboard.ts +++ b/Frontend/erp-system/lib/api/dashboard.ts @@ -1,9 +1,15 @@ // Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs). import { apiRequest, buildQuery } from "@/lib/api-client" -import { DashboardStats } from "@/types/dashboard" +import { DashboardChartsData, DashboardStats } from "@/types/dashboard" export const dashboardApi = { - stats(params: { branchId?: number } = {}): Promise { + /** `from` / `to` (YYYY-MM-DD) set the period for the activity counts; omitted means today. */ + stats(params: { branchId?: number; from?: string; to?: string } = {}): Promise { return apiRequest(`/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 { + return apiRequest(`/dashboard/charts${buildQuery(params)}`) + }, } diff --git a/Frontend/erp-system/package-lock.json b/Frontend/erp-system/package-lock.json index 1c404c6..97c1bff 100644 --- a/Frontend/erp-system/package-lock.json +++ b/Frontend/erp-system/package-lock.json @@ -16,6 +16,7 @@ "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.5.0", "date-fns": "^4.4.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", @@ -28,6 +29,7 @@ "react-dom": "19.2.4", "react-hook-form": "^7.81.0", "react-loading-skeleton": "^3.5.0", + "recharts": "^3.8.0", "shadcn": "^4.13.0", "tailwind-merge": "^3.6.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" } }, + "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1934,6 +1972,12 @@ "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": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -2398,6 +2442,12 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -2413,6 +2463,12 @@ "@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": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2422,12 +2478,48 @@ "@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": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "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": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -2518,6 +2610,12 @@ "license": "MIT", "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": { "version": "4.0.2", "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_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": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -4186,6 +4296,18 @@ "devOptional": true, "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -4226,6 +4348,15 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -4238,6 +4369,31 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -4247,6 +4403,42 @@ "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": { "version": "3.0.1", "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": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4861,6 +5059,18 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5317,6 +5527,12 @@ "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": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6093,6 +6309,16 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6140,6 +6366,15 @@ "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": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", @@ -8484,7 +8719,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-loading-skeleton": { @@ -8496,6 +8730,29 @@ "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": { "version": "0.23.12", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", @@ -8512,6 +8769,57 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10090,6 +10398,28 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/Frontend/erp-system/package.json b/Frontend/erp-system/package.json index 665b385..5d77f0e 100644 --- a/Frontend/erp-system/package.json +++ b/Frontend/erp-system/package.json @@ -17,6 +17,7 @@ "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.5.0", "date-fns": "^4.4.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", @@ -29,6 +30,7 @@ "react-dom": "19.2.4", "react-hook-form": "^7.81.0", "react-loading-skeleton": "^3.5.0", + "recharts": "^3.8.0", "shadcn": "^4.13.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", diff --git a/Frontend/erp-system/types/dashboard.ts b/Frontend/erp-system/types/dashboard.ts index 2a207aa..03ec93f 100644 --- a/Frontend/erp-system/types/dashboard.ts +++ b/Frontend/erp-system/types/dashboard.ts @@ -20,6 +20,36 @@ export interface DashboardStats { pendingLeaveRequests: number overdueSalesInvoices: number 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". @@ -29,3 +59,53 @@ export interface MonthlyPurchasesVsSales { purchases: 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[] +}