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:
@@ -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<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
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
|
||||
// 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<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||
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;
|
||||
_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<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,
|
||||
// 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<WarehouseValuationDto>();
|
||||
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<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(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,5 +5,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user