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

- Implemented DashboardDateFilter component for selecting date ranges including custom options.
- Created DashboardStatGroups component to display various statistical cards grouped by categories.
- Added dashboard-chart-data module to handle chart specifications and data processing for dashboard charts.
- Introduced Chart component with tooltip and legend functionalities for better data visualization.
This commit is contained in:
2026-10-21 14:58:21 +05:30
parent 0a212ed1aa
commit f100a0260d
11 changed files with 13522 additions and 0 deletions
@@ -0,0 +1,46 @@
namespace ERPCore.Dtos.Dashboard;
/// <summary>
/// Every dataset behind the dashboard graphs, in one computed-on-read object (same idea as
/// <see cref="DashboardStatsDto"/>). Time-based sets follow the From-To period the dashboard filter sends;
/// "snapshot" sets (ageing, batches expiring, low stock, warranties) describe what is open now.
/// Purchases-vs-sales by month and stock value by warehouse are already in <see cref="DashboardStatsDto"/>.
/// </summary>
public sealed record DashboardChartsDto(
// Sales
IReadOnlyList<DailySalesPointDto> DailySales,
IReadOnlyList<ChartPointDto> PaymentSplit,
IReadOnlyList<ChartPointDto> TopItems,
IReadOnlyList<ChartPointDto> SalesByCategory,
IReadOnlyList<ChartPointDto> SalesByCustomer,
IReadOnlyList<DocumentStatusDto> DocumentStatus,
// Money
IReadOnlyList<ChartPointDto> CashInHandByDay,
IReadOnlyList<ChartPointDto> ReceivablesAgeing,
IReadOnlyList<ChartPointDto> PayablesAgeing,
IReadOnlyList<ChartPointDto> ExpenseBreakdown,
// Purchasing
IReadOnlyList<ChartPointDto> TopVendors,
// Stock
IReadOnlyList<ChartPairDto> StockInVsOut,
IReadOnlyList<ChartPointDto> BatchesExpiring,
// HR
IReadOnlyList<ChartPointDto> AttendanceMix,
IReadOnlyList<ChartPointDto> PayrollCostByMonth,
IReadOnlyList<ChartPointDto> LeaveStatus,
// Service and production
IReadOnlyList<ChartPairDto> TicketsOverTime,
IReadOnlyList<ChartPointDto> ProductionStatus,
IReadOnlyList<ChartPointDto> WarrantyExpiryByMonth);
/// <summary>One labelled value: a bar, a donut slice or a point on a line.</summary>
public sealed record ChartPointDto(string Label, decimal Value);
/// <summary>Two values under one label (stock in/out by warehouse, tickets opened/resolved by day).</summary>
public sealed record ChartPairDto(string Label, decimal First, decimal Second);
/// <summary>One day of the Daily Sales, Gross Profit and Expenses line chart.</summary>
public sealed record DailySalesPointDto(string Label, decimal Sales, decimal GrossProfit, decimal Expenses);
/// <summary>Draft / Posted / Cancelled counts for one kind of document.</summary>
public sealed record DocumentStatusDto(string Doc, int Draft, int Posted, int Cancelled);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddDashboardPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 59, "dashboard.stock", "/dashboard", null, "Stock", 1, 1 },
{ 60, "dashboard.procurement", "/dashboard", null, "Procurement", 1, 2 },
{ 61, "dashboard.approvals", "/dashboard", null, "Approvals", 1, 3 },
{ 62, "dashboard.sales", "/dashboard", null, "Sales", 1, 4 },
{ 63, "dashboard.accounts", "/dashboard", null, "Accounts", 1, 5 },
{ 64, "dashboard.hrm", "/dashboard", null, "Human Resources", 1, 6 },
{ 65, "dashboard.production", "/dashboard", null, "Production", 1, 7 },
{ 66, "dashboard.service", "/dashboard", null, "Service & Warranty", 1, 8 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 72, "NAV:dashboard.stock", null, 59 },
{ 73, "NAV:dashboard.procurement", null, 60 },
{ 74, "NAV:dashboard.approvals", null, 61 },
{ 75, "NAV:dashboard.sales", null, 62 },
{ 76, "NAV:dashboard.accounts", null, 63 },
{ 77, "NAV:dashboard.hrm", null, 64 },
{ 78, "NAV:dashboard.production", null, 65 },
{ 79, "NAV:dashboard.service", null, 66 }
});
// Admin gets every new dashboard area. Any other role keeps what it could already see: it is granted
// view on an area when it already holds a matching module permission (idempotent, like AddPoApprovalsNav).
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT r.`RoleId`, p.`PermissionId`, 31
FROM `roles` r
JOIN `permissions` p ON p.`PermissionId` BETWEEN 72 AND 79
WHERE LOWER(r.`Code`) = 'admin';
""");
// stock: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 72, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:stock'
OR src.`Code` LIKE 'NAV:stock.%';
""");
// procurement: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 73, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:procurement'
OR src.`Code` LIKE 'NAV:procurement.%'
OR src.`Code` = 'NAV:receiving';
""");
// approvals: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 74, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:settings.approvals'
OR src.`Code` = 'NAV:procurement.po-approvals'
OR src.`Code` = 'NAV:products.price-adjustments'
OR src.`Code` = 'NAV:stock.wastage';
""");
// sales: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 75, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:sales'
OR src.`Code` LIKE 'NAV:sales.%'
OR src.`Code` = 'NAV:sales-returns';
""");
// accounts: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 76, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:accounts.bank-deposits'
OR src.`Code` = 'NAV:accounts.expenses';
""");
// hrm: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 77, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:hrm'
OR src.`Code` LIKE 'NAV:hrm.%';
""");
// production: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 78, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:production'
OR src.`Code` LIKE 'NAV:production.%';
""");
// service: view for any role that could already reach the matching module.
migrationBuilder.Sql("""
INSERT IGNORE INTO `role_permissions` (`RoleId`, `PermissionId`, `Actions`)
SELECT DISTINCT rp.`RoleId`, 79, 1
FROM `role_permissions` rp
JOIN `permissions` src ON src.`PermissionId` = rp.`PermissionId`
WHERE src.`Code` = 'NAV:help'
OR src.`Code` = 'NAV:sales'
OR src.`Code` LIKE 'NAV:sales.%';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM `role_permissions` WHERE `PermissionId` BETWEEN 72 AND 79;");
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 72);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 73);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 74);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 75);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 76);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 77);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 78);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 79);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 59);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 60);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 61);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 62);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 63);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 64);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 65);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 66);
}
}
}
@@ -0,0 +1,18 @@
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
/// <summary>
/// The dashboard shows figures from many modules, but a role only holds some of them. Settings -> Roles has a
/// "Dashboard" row with one switch per area (<c>dashboard.stock</c>, <c>dashboard.sales</c>, ...), seeded like every
/// other nav permission. Each stat card and graph belongs to one area, and its figure is only computed when the
/// caller's role holds that area - so a role never receives a number it was not given, and the queries for it are
/// never run. Same rule as <see cref="NavAuthorizationHandler"/>: administrators pass everything, everyone else
/// needs the role's permission.
/// </summary>
internal static class DashboardAccess
{
public static async Task<bool> CanAsync(this INavPermissionService nav, ICurrentUser user, string navCode, CancellationToken ct = default)
=> user.IsAdmin || await nav.HasAccessAsync(user.RoleId, navCode, ct);
}
@@ -0,0 +1,505 @@
using System.Globalization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Dashboard;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services;
/// <summary>
/// The datasets behind the dashboard graphs, pulled straight from each domain's repository like
/// <see cref="DashboardService"/> - grouped and summed in SQL, only the small result comes back. Daily sales and
/// cash reuse the day-end report and cash-in-hand services so the graphs agree with those screens.
/// </summary>
public sealed class DashboardChartsService : IDashboardChartsService
{
private readonly IRepository<SalesInvoice> _salesInvoices;
private readonly IRepository<SalesInvoiceLine> _salesLines;
private readonly IRepository<SalesSlip> _salesSlips;
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
private readonly IRepository<Expense> _expenses;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<Batch> _batches;
private readonly IRepository<AttendanceRecord> _attendance;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IRepository<LeaveRequest> _leaveRequests;
private readonly IRepository<SupportTicket> _tickets;
private readonly IRepository<ProductionRun> _productionRuns;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IDayEndReportService _dayEnd;
private readonly ICashInHandService _cash;
private readonly BusinessDayClock _clock;
private readonly IMemoryCache _cache;
private readonly ICurrentUser _user;
private readonly INavPermissionService _nav;
// Bars / slices shown before the rest are folded into "Other".
private const int TopN = 5;
private const int TopItemsN = 10;
private const int PayrollMonths = 12;
// The day-end and cash services cap a range at 92 days; daily graphs never need fewer than a week to read.
private const int MaxDailyDays = 92;
private const int MinDailyDays = 7;
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
public DashboardChartsService(
IRepository<SalesInvoice> salesInvoices, IRepository<SalesInvoiceLine> salesLines, IRepository<SalesSlip> salesSlips,
IRepository<PurchaseInvoice> purchaseInvoices, IRepository<Expense> expenses, IRepository<StockLedger> ledger,
IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Batch> batches,
IRepository<AttendanceRecord> attendance, IRepository<PayrollLine> payrollLines, IRepository<LeaveRequest> leaveRequests,
IRepository<SupportTicket> tickets, IRepository<ProductionRun> productionRuns, IRepository<WarrantyRegistration> warranties,
IDayEndReportService dayEnd, ICashInHandService cash, BusinessDayClock clock, IMemoryCache cache, ICurrentUser user, INavPermissionService nav)
{
_salesInvoices = salesInvoices;
_salesLines = salesLines;
_salesSlips = salesSlips;
_purchaseInvoices = purchaseInvoices;
_expenses = expenses;
_ledger = ledger;
_warehouses = warehouses;
_items = items;
_batches = batches;
_attendance = attendance;
_payrollLines = payrollLines;
_leaveRequests = leaveRequests;
_tickets = tickets;
_productionRuns = productionRuns;
_warranties = warranties;
_dayEnd = dayEnd;
_cash = cash;
_clock = clock;
_cache = cache;
_user = user;
_nav = nav;
}
public async Task<DashboardChartsDto> GetChartsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{
// Same short per-user cache as the stat cards (see DashboardService): repeat loads and filter flips are instant.
var key = $"dashboard-charts:{_user.UserId}:{branchId}:{from}:{to}";
if (_cache.TryGetValue(key, out DashboardChartsDto? cached) && cached is not null) return cached;
var charts = await ComputeAsync(branchId, from, to, ct);
_cache.Set(key, charts, CacheTtl);
return charts;
}
/// <summary>Whether the caller's role may reach the screen a graph belongs to; see <see cref="DashboardAccess"/>.</summary>
private Task<bool> CanAsync(string navCode, CancellationToken ct) => _nav.CanAsync(_user, navCode, ct);
private async Task<DashboardChartsDto> ComputeAsync(int? branchId, DateOnly? from, DateOnly? to, CancellationToken ct)
{
// Each graph belongs to a dashboard area (Settings -> Roles -> Dashboard: stock, procurement, sales, accounts, hrm,
// production, service). A role without the area's permission gets an empty set and the queries are not run
// (administrators pass every check) - see DashboardAccess.
var canDayEnd = await CanAsync("dashboard.sales", ct);
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
var canService = await CanAsync("dashboard.service", ct);
var canBankDeposits = await CanAsync("dashboard.accounts", ct);
var canExpenses = await CanAsync("dashboard.accounts", ct);
var canProcurement = await CanAsync("dashboard.procurement", ct);
var canLedger = await CanAsync("dashboard.stock", ct);
var canStock = await CanAsync("dashboard.stock", ct);
var canAttendance = await CanAsync("dashboard.hrm", ct);
var canPayroll = await CanAsync("dashboard.hrm", ct);
var canLeave = await CanAsync("dashboard.hrm", ct);
var canProduction = await CanAsync("dashboard.production", ct);
// Sequential awaits: these repositories share one scoped DbContext, which cannot run concurrent operations.
// "Today" and day boundaries follow the business time zone, like the day-end report and cash in hand.
var today = _clock.Today;
var periodFrom = from ?? to ?? today;
var periodTo = to ?? periodFrom;
if (periodTo < periodFrom) (periodFrom, periodTo) = (periodTo, periodFrom);
// Timestamps are compared against the business day's UTC bounds; plain document / attendance dates 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);
// Day-by-day graphs: the period itself, widened to a readable week and capped at what the services allow.
var dailyTo = periodTo;
var dailyFrom = periodFrom;
if (dailyTo.DayNumber - dailyFrom.DayNumber + 1 < MinDailyDays) dailyFrom = dailyTo.AddDays(-(MinDailyDays - 1));
if (dailyTo.DayNumber - dailyFrom.DayNumber + 1 > MaxDailyDays) dailyFrom = dailyTo.AddDays(-(MaxDailyDays - 1));
// ---- Sales: daily figures and payment split come from the day-end report.
var dailySales = new List<DailySalesPointDto>();
var paymentSplit = new List<ChartPointDto>();
if (canDayEnd)
{
var daily = await _dayEnd.GetDailyAsync(dailyFrom, dailyTo, branchId, ct);
dailySales = daily.Days
.Select(d => new DailySalesPointDto(DayLabel(d.Date), d.TotalSales, d.GrossProfit, d.ExpenseTotal))
.ToList();
paymentSplit = new List<ChartPointDto>
{
new("Cash", daily.Totals.PaidByCash),
new("Card", daily.Totals.PaidByCard),
new("Bank / cheque", daily.Totals.PaidByOther),
};
}
var topItems = new List<ChartPointDto>();
IReadOnlyList<ChartPointDto> salesByCategory = new List<ChartPointDto>();
var salesByCustomer = new List<ChartPointDto>();
if (canSalesInvoices)
{
var invoices = _salesInvoices.Query().AsNoTracking()
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= fromAt && i.InvoiceDate < toBefore);
if (branchId is not null) invoices = invoices.Where(i => i.BranchId == branchId);
var lines = _salesLines.Query().AsNoTracking()
.Where(l => l.SalesInvoice!.Status == SalesInvoiceStatus.Posted
&& l.SalesInvoice.InvoiceDate >= fromAt && l.SalesInvoice.InvoiceDate < toBefore);
if (branchId is not null) lines = lines.Where(l => l.SalesInvoice!.BranchId == branchId);
var topItemRows = await lines
.GroupBy(l => l.ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(x => x.LineTotal) })
.OrderByDescending(x => x.Total)
.Take(TopItemsN)
.ToListAsync(ct);
var itemIds = topItemRows.Select(r => r.ItemId).ToList();
var itemNames = await _items.Query().AsNoTracking()
.Where(i => itemIds.Contains(i.ItemId))
.ToDictionaryAsync(i => i.ItemId, i => i.Name, ct);
topItems = topItemRows.Select(r => new ChartPointDto(itemNames.GetValueOrDefault(r.ItemId, $"#{r.ItemId}"), r.Total)).ToList();
var categoryRows = await lines
.GroupBy(l => l.Item!.Category!.Name)
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.LineTotal) })
.OrderByDescending(x => x.Total)
.ToListAsync(ct);
salesByCategory = TopWithOther(categoryRows.Select(r => new ChartPointDto(r.Name, r.Total)), TopN);
var customerRows = await invoices
.GroupBy(i => i.CustomerSnapshotName)
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.GrandTotal) })
.OrderByDescending(x => x.Total)
.Take(TopN)
.ToListAsync(ct);
salesByCustomer = customerRows.Select(r => new ChartPointDto(r.Name, r.Total)).ToList();
}
var documentStatus = await GetDocumentStatusAsync(branchId, fromAt, toBefore, fromDay, toDayEnd, periodFrom, periodTo, ct);
// ---- Money
var cashInHandByDay = canBankDeposits
? (await _cash.GetRangeAsync(dailyFrom, dailyTo, branchId, ct)).Select(c => new ChartPointDto(DayLabel(c.Date), c.Closing)).ToList()
: new List<ChartPointDto>();
var receivablesAgeing = canSalesInvoices ? await GetReceivablesAgeingAsync(branchId, ct) : new List<ChartPointDto>();
var payablesAgeing = canProcurement ? await GetPayablesAgeingAsync(branchId, ct) : new List<ChartPointDto>();
IReadOnlyList<ChartPointDto> expenseBreakdown = new List<ChartPointDto>();
if (canExpenses)
{
var expenses = _expenses.Query().AsNoTracking()
.Where(e => e.Status == ExpenseStatus.Posted && e.ExpenseDate >= periodFrom && e.ExpenseDate <= periodTo);
if (branchId is not null) expenses = expenses.Where(e => e.BranchId == branchId);
var expenseRows = await expenses
.GroupBy(e => e.Category!.Name)
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.Amount) })
.OrderByDescending(x => x.Total)
.ToListAsync(ct);
expenseBreakdown = TopWithOther(expenseRows.Select(r => new ChartPointDto(r.Name, r.Total)), TopN);
}
// ---- Purchasing
var topVendors = new List<ChartPointDto>();
if (canProcurement)
{
var purchases = _purchaseInvoices.Query().AsNoTracking()
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= fromDay && i.VendorInvoiceDate < toDayEnd);
if (branchId is not null) purchases = purchases.Where(i => i.BranchId == branchId);
var vendorRows = await purchases
.GroupBy(i => i.VendorSnapshotName)
.Select(g => new { Name = g.Key, Total = g.Sum(x => x.GrandTotal) })
.OrderByDescending(x => x.Total)
.Take(TopN)
.ToListAsync(ct);
topVendors = vendorRows.Select(r => new ChartPointDto(r.Name, r.Total)).ToList();
}
// ---- Stock
var stockInVsOut = canLedger ? await GetStockInVsOutAsync(branchId, fromAt, toBefore, ct) : new List<ChartPairDto>();
var batchesExpiring = canStock ? await GetBatchesExpiringAsync(today, ct) : new List<ChartPointDto>();
// ---- HR
var attendanceMix = new List<ChartPointDto>();
if (canAttendance)
{
var attendance = _attendance.Query().AsNoTracking()
.Where(a => a.AttendanceDate >= fromDay && a.AttendanceDate < toDayEnd);
if (branchId is not null) attendance = attendance.Where(a => a.Employee!.BranchId == branchId);
var attendanceRows = await attendance
.GroupBy(a => a.AttendanceStatus)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct);
attendanceMix = attendanceRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
}
var payrollCostByMonth = canPayroll ? await GetPayrollCostAsync(branchId, today, ct) : new List<ChartPointDto>();
var leaveStatus = new List<ChartPointDto>();
if (canLeave)
{
var leaves = _leaveRequests.Query().AsNoTracking().Where(l => l.CreatedAt >= fromAt && l.CreatedAt < toBefore);
var leaveRows = await leaves.GroupBy(l => l.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync(ct);
leaveStatus = leaveRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
}
// ---- Service and production
var ticketsOverTime = canService ? await GetTicketsOverTimeAsync(dailyFrom, dailyTo, ct) : new List<ChartPairDto>();
var productionStatus = new List<ChartPointDto>();
if (canProduction)
{
var runs = _productionRuns.Query().AsNoTracking().Where(r => r.CreatedAt >= fromAt && r.CreatedAt < toBefore);
if (branchId is not null) runs = runs.Where(r => r.Warehouse!.BranchId == branchId);
var runRows = await runs.GroupBy(r => r.Status).Select(g => new { Status = g.Key, Count = g.Count() }).ToListAsync(ct);
productionStatus = runRows.Select(r => new ChartPointDto(Spaced(r.Status.ToString()), r.Count)).ToList();
}
var warrantyExpiryByMonth = canService ? await GetWarrantyExpiryAsync(today, ct) : new List<ChartPointDto>();
return new DashboardChartsDto(
dailySales, paymentSplit, topItems, salesByCategory, salesByCustomer, documentStatus,
cashInHandByDay, receivablesAgeing, payablesAgeing, expenseBreakdown,
topVendors,
stockInVsOut, batchesExpiring,
attendanceMix, payrollCostByMonth, leaveStatus,
ticketsOverTime, productionStatus, warrantyExpiryByMonth);
}
// ------------------------------------------------------------------ helpers
private static string DayLabel(DateOnly? d) => d is null ? "" : d.Value.ToString("d MMM", CultureInfo.InvariantCulture);
/// <summary>"InProgress" -> "In progress", "HalfDay" -> "Half day".</summary>
private static string Spaced(string name)
{
var chars = new List<char>();
for (var i = 0; i < name.Length; i++)
{
if (i > 0 && char.IsUpper(name[i])) chars.Add(' ');
chars.Add(i > 0 && char.IsUpper(name[i]) ? char.ToLowerInvariant(name[i]) : name[i]);
}
return new string(chars.ToArray());
}
/// <summary>The biggest <paramref name="n"/> rows as they are, everything smaller folded into one "Other" slice.</summary>
private static IReadOnlyList<ChartPointDto> TopWithOther(IEnumerable<ChartPointDto> sortedDescending, int n)
{
var all = sortedDescending.ToList();
if (all.Count <= n) return all;
var top = all.Take(n).ToList();
top.Add(new ChartPointDto("Other", all.Skip(n).Sum(p => p.Value)));
return top;
}
private async Task<IReadOnlyList<DocumentStatusDto>> GetDocumentStatusAsync(
int? branchId, DateTime fromAt, DateTime toBefore, DateTime fromDay, DateTime toDayEnd, DateOnly periodFrom, DateOnly periodTo,
CancellationToken ct)
{
// One row per kind of document, and only for the modules the caller may reach.
var canSalesInvoices = await CanAsync("dashboard.sales", ct);
var canProcurement = await CanAsync("dashboard.procurement", ct);
var canExpenses = await CanAsync("dashboard.accounts", ct);
// Sales invoices and slips number their statuses from 1 (Draft=1, Posted=2, Cancelled=3); purchase invoices
// and expenses from 0. Each is mapped through its own enum, never by position.
int Count<T>(IEnumerable<(int Status, int N)> rows, T status) where T : struct, Enum
=> rows.Where(r => r.Status == Convert.ToInt32(status)).Sum(r => r.N);
var result = new List<DocumentStatusDto>();
if (canSalesInvoices)
{
var invoices = _salesInvoices.Query().AsNoTracking().Where(i => i.InvoiceDate >= fromAt && i.InvoiceDate < toBefore);
if (branchId is not null) invoices = invoices.Where(i => i.BranchId == branchId);
var inv = (await invoices.GroupBy(i => i.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
.Select(r => (r.Status, r.N)).ToList();
var slips = _salesSlips.Query().AsNoTracking().Where(s => s.SlipDate >= fromAt && s.SlipDate < toBefore);
if (branchId is not null) slips = slips.Where(s => s.Warehouse!.BranchId == branchId);
var slp = (await slips.GroupBy(s => s.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
.Select(r => (r.Status, r.N)).ToList();
result.Add(new("Sales invoices", Count(inv, SalesInvoiceStatus.Draft), Count(inv, SalesInvoiceStatus.Posted), Count(inv, SalesInvoiceStatus.Cancelled)));
result.Add(new("Sales slips", Count(slp, SalesSlipStatus.Draft), Count(slp, SalesSlipStatus.Posted), Count(slp, SalesSlipStatus.Cancelled)));
}
if (canProcurement)
{
var purchases = _purchaseInvoices.Query().AsNoTracking().Where(i => i.VendorInvoiceDate >= fromDay && i.VendorInvoiceDate < toDayEnd);
if (branchId is not null) purchases = purchases.Where(i => i.BranchId == branchId);
var pur = (await purchases.GroupBy(i => i.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
.Select(r => (r.Status, r.N)).ToList();
result.Add(new("Purchase invoices", Count(pur, PurchaseInvoiceStatus.Draft), Count(pur, PurchaseInvoiceStatus.Posted), Count(pur, PurchaseInvoiceStatus.Cancelled)));
}
if (canExpenses)
{
var expenses = _expenses.Query().AsNoTracking().Where(e => e.ExpenseDate >= periodFrom && e.ExpenseDate <= periodTo);
if (branchId is not null) expenses = expenses.Where(e => e.BranchId == branchId);
var exp = (await expenses.GroupBy(e => e.Status).Select(g => new { Status = (int)g.Key, N = g.Count() }).ToListAsync(ct))
.Select(r => (r.Status, r.N)).ToList();
result.Add(new("Expenses", Count(exp, ExpenseStatus.Draft), Count(exp, ExpenseStatus.Posted), Count(exp, ExpenseStatus.Cancelled)));
}
return result;
}
/// <summary>Unpaid Posted invoices bucketed by how old the invoice is. Neither invoice type carries a due date,
/// so age runs from the invoice date (the same approach DashboardService uses for "overdue").</summary>
private async Task<IReadOnlyList<ChartPointDto>> GetReceivablesAgeingAsync(int? branchId, CancellationToken ct)
{
var open = _salesInvoices.Query().AsNoTracking().Where(i => i.Status == SalesInvoiceStatus.Posted && i.BalanceAmount > 0);
if (branchId is not null) open = open.Where(i => i.BranchId == branchId);
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d60 = now.AddDays(-60);
var d90 = now.AddDays(-90);
return new List<ChartPointDto>
{
new("0-30 days", await open.Where(i => i.InvoiceDate >= d30).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("31-60 days", await open.Where(i => i.InvoiceDate < d30 && i.InvoiceDate >= d60).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("61-90 days", await open.Where(i => i.InvoiceDate < d60 && i.InvoiceDate >= d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("90+ days", await open.Where(i => i.InvoiceDate < d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
};
}
private async Task<IReadOnlyList<ChartPointDto>> GetPayablesAgeingAsync(int? branchId, CancellationToken ct)
{
var open = _purchaseInvoices.Query().AsNoTracking().Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.BalanceAmount > 0);
if (branchId is not null) open = open.Where(i => i.BranchId == branchId);
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d60 = now.AddDays(-60);
var d90 = now.AddDays(-90);
return new List<ChartPointDto>
{
new("0-30 days", await open.Where(i => i.VendorInvoiceDate >= d30).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("31-60 days", await open.Where(i => i.VendorInvoiceDate < d30 && i.VendorInvoiceDate >= d60).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("61-90 days", await open.Where(i => i.VendorInvoiceDate < d60 && i.VendorInvoiceDate >= d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
new("90+ days", await open.Where(i => i.VendorInvoiceDate < d90).SumAsync(i => (decimal?)i.BalanceAmount, ct) ?? 0m),
};
}
private async Task<IReadOnlyList<ChartPairDto>> GetStockInVsOutAsync(int? branchId, DateTime fromAt, DateTime toBefore, CancellationToken ct)
{
var ledger = _ledger.Query().AsNoTracking().Where(l => l.CreatedAt >= fromAt && l.CreatedAt < toBefore);
if (branchId is not null)
{
var branchWarehouses = _warehouses.Query().Where(w => w.BranchId == branchId).Select(w => w.WarehouseId);
ledger = ledger.Where(l => branchWarehouses.Contains(l.WarehouseId));
}
var rows = await ledger
.GroupBy(l => new { l.WarehouseId, l.Direction })
.Select(g => new { g.Key.WarehouseId, g.Key.Direction, Qty = g.Sum(x => x.QtyBase) })
.ToListAsync(ct);
var codes = await _warehouses.Query().AsNoTracking().ToDictionaryAsync(w => w.WarehouseId, w => w.Code, ct);
return rows
.GroupBy(r => r.WarehouseId)
.Select(g => new ChartPairDto(
codes.GetValueOrDefault(g.Key, $"#{g.Key}"),
g.Where(r => r.Direction == Direction.In).Sum(r => r.Qty),
g.Where(r => r.Direction == Direction.Out).Sum(r => r.Qty)))
.OrderByDescending(p => p.First + p.Second)
.ToList();
}
private async Task<IReadOnlyList<ChartPointDto>> GetBatchesExpiringAsync(DateOnly today, CancellationToken ct)
{
var batches = _batches.Query().AsNoTracking().Where(b => b.ExpiryDate != null && b.ExpiryDate >= today);
var d30 = today.AddDays(30);
var d60 = today.AddDays(60);
var d90 = today.AddDays(90);
return new List<ChartPointDto>
{
new("Next 30 days", await batches.CountAsync(b => b.ExpiryDate <= d30, ct)),
new("31-60 days", await batches.CountAsync(b => b.ExpiryDate > d30 && b.ExpiryDate <= d60, ct)),
new("61-90 days", await batches.CountAsync(b => b.ExpiryDate > d60 && b.ExpiryDate <= d90, ct)),
};
}
/// <summary>Net salary of Approved / Locked payroll runs for each of the last <see cref="PayrollMonths"/> months.</summary>
private async Task<IReadOnlyList<ChartPointDto>> GetPayrollCostAsync(int? branchId, DateOnly today, CancellationToken ct)
{
var start = new DateOnly(today.Year, today.Month, 1).AddMonths(-(PayrollMonths - 1));
var startKey = start.Year * 100 + start.Month;
var lines = _payrollLines.Query().AsNoTracking()
.Where(l => (l.PayrollRun!.Status == PayrollRunStatus.Approved || l.PayrollRun.Status == PayrollRunStatus.Locked)
&& l.PayrollRun.PeriodYear * 100 + l.PayrollRun.PeriodMonth >= startKey);
if (branchId is not null) lines = lines.Where(l => l.PayrollRun!.BranchId == branchId);
var rows = await lines
.GroupBy(l => new { l.PayrollRun!.PeriodYear, l.PayrollRun.PeriodMonth })
.Select(g => new { g.Key.PeriodYear, g.Key.PeriodMonth, Total = g.Sum(x => x.NetSalary) })
.ToListAsync(ct);
var byMonth = rows.ToDictionary(r => (r.PeriodYear, r.PeriodMonth), r => r.Total);
return Enumerable.Range(0, PayrollMonths)
.Select(i => start.AddMonths(i))
.Select(m => new ChartPointDto(m.ToString("MMM", CultureInfo.InvariantCulture), byMonth.GetValueOrDefault((m.Year, m.Month))))
.ToList();
}
/// <summary>Tickets opened and tickets resolved on each day of the window. A ticket carries no resolved date, so
/// the time it was last updated while Resolved stands in for it.</summary>
private async Task<IReadOnlyList<ChartPairDto>> GetTicketsOverTimeAsync(DateOnly from, DateOnly to, CancellationToken ct)
{
var fromAt = _clock.ToUtc(from);
var toBefore = _clock.ToUtc(to.AddDays(1));
var opened = await _tickets.Query().AsNoTracking()
.Where(t => t.CreatedAt >= fromAt && t.CreatedAt < toBefore)
.Select(t => t.CreatedAt)
.ToListAsync(ct);
var resolved = await _tickets.Query().AsNoTracking()
.Where(t => t.Status == TicketStatus.Resolved && t.UpdatedAt != null && t.UpdatedAt >= fromAt && t.UpdatedAt < toBefore)
.Select(t => t.UpdatedAt!.Value)
.ToListAsync(ct);
var openedByDay = opened.GroupBy(d => _clock.LocalDay(d)).ToDictionary(g => g.Key, g => g.Count());
var resolvedByDay = resolved.GroupBy(d => _clock.LocalDay(d)).ToDictionary(g => g.Key, g => g.Count());
return Enumerable.Range(0, to.DayNumber - from.DayNumber + 1)
.Select(i => from.AddDays(i))
.Select(d => new ChartPairDto(DayLabel(d), openedByDay.GetValueOrDefault(d), resolvedByDay.GetValueOrDefault(d)))
.ToList();
}
/// <summary>Active warranty registrations by the month they end, for the next six months.</summary>
private async Task<IReadOnlyList<ChartPointDto>> GetWarrantyExpiryAsync(DateOnly today, CancellationToken ct)
{
var start = new DateOnly(today.Year, today.Month, 1);
var end = start.AddMonths(6);
var ends = await _warranties.Query().AsNoTracking()
.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= start && w.EndDate < end)
.Select(w => w.EndDate)
.ToListAsync(ct);
var byMonth = ends.GroupBy(d => (d.Year, d.Month)).ToDictionary(g => g.Key, g => g.Count());
return Enumerable.Range(0, 6)
.Select(i => start.AddMonths(i))
.Select(m => new ChartPointDto(m.ToString("MMM", CultureInfo.InvariantCulture), byMonth.GetValueOrDefault((m.Year, m.Month))))
.ToList();
}
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Dashboard;
namespace ERPCore.Services.Interfaces;
/// <summary>Datasets for the dashboard graphs.</summary>
public interface IDashboardChartsService
{
/// <param name="from">Start of the period for the time-based graphs (default: today).</param>
/// <param name="to">End of the period, inclusive (default: <paramref name="from"/>, else today).</param>
Task<DashboardChartsDto> GetChartsAsync(int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
}
@@ -0,0 +1,197 @@
"use client"
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, XAxis, YAxis } from "recharts"
import { useId, useMemo } from "react"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useAuth } from "@/components/auth/AuthProvider"
import { buildChartSpecs, canSeeChart, CHART_SECTIONS, isChartEmpty, type BarChartProps, type ChartSpec, type LineChartProps, type Series } from "@/components/dashboard/dashboard-chart-data"
// The dashboard palette is the theme red (--primary) paired with a slate ash (--chart-ink in globals.css), plus
// lighter tints of each, so every chart reads as one set in the brand colours.
const tint = (base: string, pct: number) => `color-mix(in oklab, ${base} ${pct}%, var(--background))`
const RED = "var(--primary)"
const ASH = "var(--chart-ink)"
// Several series: ash and red side by side (clearly different), then a light ash for a third.
const SERIES_SHADES = [ASH, RED, tint(ASH, 35)]
// Donuts alternate red and ash, fading lighter, so neighbouring slices never look alike.
const SLICE_SHADES = [RED, ASH, tint(RED, 60), tint(ASH, 55), tint(ASH, 30)]
// A chart with one series is the brand red; with several it uses the ash / red pairing above.
const seriesColor = (i: number, count: number) => (count === 1 ? RED : SERIES_SHADES[i % SERIES_SHADES.length])
const sliceColor = (i: number) => SLICE_SHADES[i % SLICE_SHADES.length]
function seriesConfig(series: Series[]): ChartConfig {
return Object.fromEntries(series.map((s, i) => [s.key, { label: s.label, color: s.color ?? seriesColor(i, series.length) }]))
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
const formatTick = (v: number | string) => (typeof v === "number" ? compact.format(v) : v)
export function BarChartView({ spec }: { spec: BarChartProps }) {
const config = seriesConfig(spec.series)
const horizontal = spec.horizontal === true
return (
<ChartContainer config={config} className="h-64 w-full">
<BarChart accessibilityLayer data={spec.data} layout={horizontal ? "vertical" : "horizontal"} margin={{ left: horizontal ? 8 : 0 }}>
<CartesianGrid horizontal={!horizontal} vertical={horizontal} />
{horizontal ? (
<>
<YAxis dataKey={spec.xKey} type="category" tickLine={false} axisLine={false} width={72} tickMargin={4} />
<XAxis type="number" hide />
</>
) : (
<>
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
</>
)}
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{spec.series.map((s) => (
<Bar
key={s.key}
dataKey={s.key}
fill={`var(--color-${s.key})`}
stackId={spec.stacked ? "a" : undefined}
maxBarSize={56}
radius={spec.stacked ? 0 : horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]}
/>
))}
</BarChart>
</ChartContainer>
)
}
export function LineChartView({ spec }: { spec: LineChartProps }) {
// Gradient ids must be unique per chart on the page, or every chart would share the first one's fill.
const gid = useId().replace(/:/g, "")
const config = seriesConfig(spec.series)
const common = (
<>
<CartesianGrid vertical={false} />
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={16} />
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
</>
)
return (
<ChartContainer config={config} className="h-64 w-full">
{spec.area ? (
<AreaChart accessibilityLayer data={spec.data}>
<defs>
{spec.series.map((s) => (
<linearGradient key={s.key} id={`${gid}-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.35} />
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{common}
{spec.series.map((s) => (
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} fill={`url(#${gid}-${s.key})`} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
))}
</AreaChart>
) : (
<LineChart accessibilityLayer data={spec.data}>
{common}
{spec.series.map((s) => (
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
))}
</LineChart>
)}
</ChartContainer>
)
}
function DonutSpec({ spec }: { spec: Extract<ChartSpec, { kind: "donut" }> }) {
const config: ChartConfig = Object.fromEntries(spec.data.map((d, i) => [d.key, { label: d.label, color: sliceColor(i) }]))
return (
<ChartContainer config={config} className="mx-auto h-64 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="key" />} />
<Pie data={spec.data} dataKey="value" nameKey="key" innerRadius={52} outerRadius={84} strokeWidth={2}>
{spec.data.map((d) => (
<Cell key={d.key} fill={`var(--color-${d.key})`} />
))}
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
</PieChart>
</ChartContainer>
)
}
function ChartBody({ spec }: { spec: ChartSpec }) {
switch (spec.kind) {
case "bar":
return <BarChartView spec={spec} />
case "line":
return <LineChartView spec={spec} />
case "donut":
return <DonutSpec spec={spec} />
}
}
export function DashboardCharts({
stats,
charts,
warehouseCodes,
error,
}: {
stats: DashboardStats | null
charts: DashboardChartsData | null
warehouseCodes: Map<number, string>
error?: boolean
}) {
const { can } = useAuth()
// A role only sees the graphs for modules it can open; the API withholds the data too (DashboardAccess).
const specs = useMemo(
() => buildChartSpecs(stats, charts, warehouseCodes).filter((spec) => canSeeChart(spec, can)),
[stats, charts, warehouseCodes, can]
)
const loading = !charts && !error
return (
<div className="flex flex-col gap-6">
{CHART_SECTIONS.map((section) => {
const sectionSpecs = specs.filter((c) => c.section === section)
if (sectionSpecs.length === 0) return null
return (
<section key={section} className="flex flex-col gap-3">
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{section}</h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{sectionSpecs.map((spec) => (
<Card key={spec.id} className="min-w-0">
<CardHeader>
<CardTitle className="text-sm sm:text-base">{spec.title}</CardTitle>
<CardDescription>{spec.description}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 rounded-lg" />
) : isChartEmpty(spec) ? (
<p className="flex h-64 items-center justify-center text-sm text-muted-foreground">No data for this period.</p>
) : (
<ChartBody spec={spec} />
)}
</CardContent>
</Card>
))}
</div>
</section>
)
})}
</div>
)
}
@@ -0,0 +1,116 @@
"use client"
import { CalendarDays, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DateRangePicker } from "@/components/ui/date-picker"
import type { CustomDateRange } from "@/components/ui/date-range-filter"
export type DashboardPeriod = "today" | "7d" | "14d" | "1m" | "2m" | "3m" | "6m" | "1y" | "custom"
export interface DashboardPeriodValue {
period: DashboardPeriod
custom?: CustomDateRange
}
const PERIODS: { value: DashboardPeriod; label: string }[] = [
{ value: "today", label: "Today" },
{ value: "7d", label: "7 Days" },
{ value: "14d", label: "14 Days" },
{ value: "1m", label: "1 Month" },
{ value: "2m", label: "2 Months" },
{ value: "3m", label: "3 Months" },
{ value: "6m", label: "6 Months" },
{ value: "1y", label: "1 Year" },
{ value: "custom", label: "Custom" },
]
const isoDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
/** Inclusive `YYYY-MM-DD` bounds for the chosen period; empty while a custom range has no start date yet. */
export function periodBounds(value: DashboardPeriodValue): { from?: string; to?: string } {
const today = new Date()
const from = new Date(today)
switch (value.period) {
case "custom":
return value.custom?.from ? { from: isoDate(value.custom.from), to: isoDate(value.custom.to ?? value.custom.from) } : {}
case "today":
break
case "7d":
from.setDate(today.getDate() - 6)
break
case "14d":
from.setDate(today.getDate() - 13)
break
case "1m":
from.setMonth(today.getMonth() - 1)
break
case "2m":
from.setMonth(today.getMonth() - 2)
break
case "3m":
from.setMonth(today.getMonth() - 3)
break
case "6m":
from.setMonth(today.getMonth() - 6)
break
case "1y":
from.setFullYear(today.getFullYear() - 1)
break
}
return { from: isoDate(from), to: isoDate(today) }
}
/** Appends the dashboard period to a link as ?from=&to= so the page it opens can apply the same dates. */
export function withPeriod(href: string, from?: string, to?: string): string {
if (!from) return href
return `${href}${href.includes("?") ? "&" : "?"}from=${from}&to=${to ?? from}`
}
/** One-line row of period buttons for the dashboard; "Custom" reveals a single-date / from–to picker. */
export function DashboardDateFilter({
value,
onChange,
}: {
value: DashboardPeriodValue
onChange: (next: DashboardPeriodValue) => void
}) {
// In custom mode the preset buttons step aside so only Custom, its picker and a way back remain.
const isCustom = value.period === "custom"
return (
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
{/* A single line that scrolls sideways on narrow screens instead of wrapping. */}
<div role="group" aria-label="Dashboard period" className="-mx-1 flex gap-1.5 overflow-x-auto px-1 py-1">
{(isCustom ? PERIODS.filter((p) => p.value === "custom") : PERIODS).map((p) => (
<Button
key={p.value}
type="button"
size="sm"
variant={value.period === p.value ? "default" : "outline"}
aria-pressed={value.period === p.value}
className="shrink-0 rounded-full"
onClick={() => onChange({ ...value, period: p.value })}
>
{p.value === "custom" && <CalendarDays className="size-4" />}
{p.label}
</Button>
))}
</div>
{isCustom && (
<>
<DateRangePicker
value={value.custom}
onChange={(custom) => onChange({ ...value, custom })}
placeholder="Pick a date or from – to"
className="sm:w-72"
/>
<Button type="button" size="sm" variant="ghost" className="shrink-0 rounded-full" onClick={() => onChange({ ...value, period: "today" })}>
<X className="size-4" />
Back
</Button>
</>
)}
</div>
)
}
@@ -0,0 +1,187 @@
import {
AlertTriangle,
Boxes,
ListChecks,
PackageCheck,
Send,
ArrowLeftRight,
BadgeDollarSign,
Banknote,
CalendarClock,
CalendarX,
ClipboardCheck,
ClipboardList,
CreditCard,
FileText,
FileWarning,
Gift,
Hammer,
HandCoins,
LifeBuoy,
PackageX,
PiggyBank,
Receipt,
ReceiptText,
RotateCcw,
ShieldCheck,
ShieldAlert,
ShoppingBag,
Tag,
Timer,
Trash2,
TrendingUp,
UserCheck,
Users,
Wallet,
Warehouse,
type LucideIcon,
} from "lucide-react"
import Link from "next/link"
import { useAuth } from "@/components/auth/AuthProvider"
import { withPeriod } from "@/components/dashboard/DashboardDateFilter"
import { DashboardStats } from "@/types/dashboard"
import { StatCard } from "@/components/ui/stat-card"
// UI only: no data is connected yet, so every value shows a dash. Nothing here calls the API.
type NumericStat = Exclude<keyof DashboardStats, "stockValuationByWarehouse" | "monthlyPurchasesVsSales">
interface Card {
label: string
/** The Dashboard area (Settings -> Roles -> Dashboard) that shows this card; a role without it never sees the card. */
code?: string
/** Page the card opens; the dashboard period is appended as ?from=&to=. */
href: string
value: number | string
icon: LucideIcon
hint?: string
/** Live number from /dashboard/stats; replaces `value` once the stats have loaded. */
stat?: NumericStat
/** Show the value and icon in the destructive colour. */
alert?: boolean
}
interface Group {
title?: string
cards: Card[]
}
const GROUPS: Group[] = [
{
// The original six cards: no heading, live numbers from /dashboard/stats.
cards: [
{ label: "Low Stock Alerts", code: "dashboard.stock", href: "/dashboard/stock/reorder-alerts", value: "—", stat: "lowStockAlerts", icon: AlertTriangle },
{ label: "Stock On-Hand", code: "dashboard.stock", href: "/dashboard/stock/enquiry", value: "—", stat: "onHandTotal", icon: Boxes },
{ label: "Pending GRNs", code: "dashboard.procurement", href: "/dashboard/receiving/grn", value: "—", stat: "pendingGrns", icon: PackageCheck },
{ label: "Open Requisitions", code: "dashboard.procurement", href: "/dashboard/procurement/requisitions", value: "—", stat: "openRequisitions", icon: ClipboardList },
{ label: "Open Counts", code: "dashboard.stock", href: "/dashboard/stock/counts", value: "—", stat: "pendingCounts", icon: ListChecks },
{ label: "Active RFQs", code: "dashboard.procurement", href: "/dashboard/procurement/rfqs", value: "—", stat: "openRfqs", icon: Send },
],
},
{
title: "Overview",
cards: [
{ label: "Stock Value", code: "dashboard.stock", stat: "stockValuationTotal", href: "/dashboard/stock/valuation", value: "—", icon: BadgeDollarSign, hint: "LKR" },
{ label: "Overdue Sales Invoices", code: "dashboard.sales", stat: "overdueSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileWarning },
{ label: "POs Awaiting Approval", code: "dashboard.approvals", stat: "pendingApprovalPurchaseOrders", href: "/dashboard/procurement/purchase-orders/approvals", value: "—", icon: ClipboardCheck },
{ label: "Pending Leave Requests", code: "dashboard.hrm", stat: "pendingLeaveRequests", href: "/dashboard/hrm/leave", value: "—", icon: CalendarClock },
{ label: "Warehouses Holding Stock", code: "dashboard.stock", stat: "onHandWarehouses", href: "/dashboard/stock/enquiry", value: "—", icon: Warehouse },
],
},
{
title: "Stock & Warehouse",
cards: [
{ label: "Batches Expiring Soon", code: "dashboard.stock", stat: "batchesExpiringSoon", href: "/dashboard/stock/batches", value: "—", icon: CalendarX, hint: "Next 30 days" },
{ label: "Transfers In Transit", code: "dashboard.stock", stat: "transfersInTransit", href: "/dashboard/stock/transfers", value: "—", icon: ArrowLeftRight },
{ label: "Draft Stock Adjustments", code: "dashboard.stock", stat: "draftStockAdjustments", href: "/dashboard/stock/adjustments", value: "—", icon: ClipboardList },
{ label: "Wastage Pending Approval", code: "dashboard.approvals", stat: "wastagePendingApproval", href: "/dashboard/stock/wastage", value: "—", icon: Trash2 },
],
},
{
title: "Approvals",
cards: [
{ label: "Pending Approvals", code: "dashboard.approvals", stat: "pendingApprovals", href: "/dashboard/approvals", value: "—", icon: ClipboardCheck },
{ label: "Price Adjustments Pending", code: "dashboard.approvals", stat: "priceAdjustmentsPending", href: "/dashboard/products/price-adjustments", value: "—", icon: Tag },
],
},
{
title: "Sales",
cards: [
{ label: "Draft Sales Invoices", code: "dashboard.sales", stat: "draftSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileText },
{ label: "Draft Sales Slips", code: "dashboard.sales", stat: "draftSalesSlips", href: "/dashboard/sales/invoices", value: "—", icon: Receipt },
{ label: "Sales Returns", code: "dashboard.sales", stat: "salesReturns", href: "/dashboard/sales/sales-returns", value: "—", icon: RotateCcw },
{ label: "Bundle Sales", code: "dashboard.sales", stat: "bundleSales", href: "/dashboard/sales/bundles", value: "—", icon: Gift },
],
},
{
title: "Purchasing",
cards: [
{ label: "Purchase Returns Pending", code: "dashboard.procurement", stat: "purchaseReturnsPending", href: "/dashboard/procurement/purchase-returns", value: "—", icon: PackageX },
{ label: "Unpaid Purchase Invoices", code: "dashboard.procurement", stat: "unpaidPurchaseInvoices", href: "/dashboard/procurement/purchase-invoices", value: "—", icon: FileWarning },
],
},
{
title: "Money",
cards: [
{ label: "Cash Not Yet Deposited", code: "dashboard.accounts", stat: "cashInHand", href: "/dashboard/accounts/bank-deposits", value: "—", icon: Wallet, hint: "LKR" },
{ label: "Bank Deposits", code: "dashboard.accounts", stat: "bankDeposits", href: "/dashboard/accounts/bank-deposits", value: "—", icon: PiggyBank, hint: "LKR" },
],
},
{
title: "Service & Warranty",
cards: [
{ label: "Open Support Tickets", code: "dashboard.service", stat: "openSupportTickets", href: "/dashboard/help", value: "—", icon: LifeBuoy },
{ label: "Warranties Expiring Soon", code: "dashboard.service", stat: "warrantiesExpiringSoon", href: "/dashboard/sales/warranties", value: "—", icon: ShieldAlert, hint: "Next 30 days" },
{ label: "Warranty Claims Open", code: "dashboard.service", stat: "warrantyClaimsOpen", href: "/dashboard/sales/warranties", value: "—", icon: ShieldCheck },
],
},
{
title: "Production",
cards: [{ label: "Production Runs In Progress", code: "dashboard.production", stat: "productionRunsInProgress", href: "/dashboard/production/runs", value: "—", icon: Hammer }],
},
{
title: "HR",
cards: [
{ label: "Payroll Run Pending", code: "dashboard.hrm", stat: "payrollRunsPending", href: "/dashboard/hrm/payroll", value: "—", icon: Timer },
{ label: "Attendance Batches Pending", code: "dashboard.hrm", stat: "attendanceBatchesPending", href: "/dashboard/hrm/attendance", value: "—", icon: AlertTriangle },
{ label: "Employees Present", code: "dashboard.hrm", stat: "employeesPresent", href: "/dashboard/hrm/attendance", value: "—", icon: UserCheck },
{ label: "Total Active Employees", code: "dashboard.hrm", stat: "totalActiveEmployees", href: "/dashboard/hrm/employees", value: "—", icon: Users },
],
},
]
export function DashboardStatGroups({ from, to, stats }: { from?: string; to?: string; stats: DashboardStats | null }) {
const { can } = useAuth()
// A role only sees the cards of the dashboard areas it was given; the API withholds the figures too (DashboardAccess).
const groups = GROUPS.map((group) => ({ ...group, cards: group.cards.filter((card) => !card.code || can(card.code)) })).filter(
(group) => group.cards.length > 0
)
return (
<div className="flex flex-col gap-6">
{groups.map((group) => (
<section key={group.title ?? "main"} className="flex flex-col gap-3">
{group.title && <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{group.title}</h2>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{group.cards.map((card) => (
<Link key={card.label} href={withPeriod(card.href, from, to)} className="block h-full rounded-2xl">
<StatCard
label={card.label}
value={card.stat && stats ? (stats[card.stat] ?? 0) : card.value}
hint={card.hint}
icon={card.icon}
compact
className="h-full"
valueClassName={card.alert ? "text-destructive" : undefined}
iconClassName={card.alert ? "bg-destructive/10 text-destructive" : undefined}
/>
</Link>
))}
</div>
</section>
))}
</div>
)
}
@@ -0,0 +1,341 @@
// Turns the dashboard's real data (GET /dashboard/charts, plus the monthly and per-warehouse figures that already
// come with GET /dashboard/stats) into the chart specs DashboardCharts draws. No numbers are made up here: an empty
// response draws an empty chart.
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
export interface Series {
key: string
label: string
/** CSS colour; defaults to the dashboard palette (ash and red), so leave it unset to keep every chart matching. */
color?: string
}
/** Which Dashboard areas (Settings -> Roles -> Dashboard) a role needs to see a graph. No rule = open to everyone. */
export interface ChartAccess {
/** Every one of these is required. */
all?: string[]
/** At least one of these is required. */
any?: string[]
}
interface BaseChart {
id: string
access?: ChartAccess
title: string
description: string
section: ChartSection
}
type Row = Record<string, string | number>
/** What the bar and line views need, so live charts on the page can reuse them with their own data. */
export interface BarChartProps {
xKey: string
series: Series[]
data: Row[]
stacked?: boolean
horizontal?: boolean
}
export interface LineChartProps {
xKey: string
series: Series[]
data: Row[]
area?: boolean
}
export type ChartSection = "Sales" | "Money" | "Purchasing" | "Stock" | "HR" | "Service & Production"
export type ChartSpec = BaseChart &
(
| ({ kind: "bar" } & BarChartProps)
| ({ kind: "line" } & LineChartProps)
| { kind: "donut"; data: { key: string; label: string; value: number }[] }
)
export const CHART_SECTIONS: ChartSection[] = ["Sales", "Money", "Purchasing", "Stock", "HR", "Service & Production"]
export function canSeeChart(spec: ChartSpec, can: (navCode: string) => boolean): boolean {
const { all, any } = spec.access ?? {}
return (all ?? []).every(can) && (!any || any.some(can))
}
/** True when a chart has nothing to draw: no rows, or every value is zero. */
export function isChartEmpty(spec: ChartSpec): boolean {
if (spec.kind === "donut") return spec.data.every((d) => d.value === 0)
return spec.data.every((row) => spec.series.every((s) => Number(row[s.key] ?? 0) === 0))
}
const slug = (label: string) => label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x"
/** "2026-03" -> "Mar 26" (a mid-month day is added so the date parses the same in every time zone). */
function monthLabel(yearMonth: string): string {
const d = new Date(`${yearMonth}-15T00:00:00`)
return Number.isNaN(d.getTime()) ? yearMonth : d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
}
export function buildChartSpecs(
stats: DashboardStats | null,
charts: DashboardChartsData | null,
warehouseCodes: Map<number, string>
): ChartSpec[] {
const slices = (rows: { label: string; value: number }[]) => rows.map((r) => ({ key: slug(r.label), label: r.label, value: r.value }))
const c = charts
return [
// ---------------------------------------------------------------- Sales
{
id: "purchases-vs-sales",
access: { all: ["dashboard.sales", "dashboard.procurement"] },
section: "Sales",
title: "Purchases vs Sales by Month",
description: "Posted invoice totals, last 12 months (LKR)",
kind: "bar",
xKey: "month",
series: [
{ key: "purchases", label: "Purchases" },
{ key: "sales", label: "Sales" },
],
data: (stats?.monthlyPurchasesVsSales ?? []).map((m) => ({ month: monthLabel(m.month), purchases: m.purchases, sales: m.sales })),
},
{
id: "daily-sales",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Daily Sales, Gross Profit and Expenses",
description: "Day by day over the selected period (LKR)",
kind: "line",
xKey: "day",
series: [
{ key: "sales", label: "Sales" },
{ key: "profit", label: "Gross profit" },
{ key: "expenses", label: "Expenses" },
],
data: (c?.dailySales ?? []).map((d) => ({ day: d.label, sales: d.sales, profit: d.grossProfit, expenses: d.expenses })),
},
{
id: "payment-split",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Payment Method Split",
description: "How customers paid in the period (LKR)",
kind: "donut",
data: slices(c?.paymentSplit ?? []),
},
{
id: "top-items",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Top 10 Selling Items",
description: "By sales value in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "item",
series: [{ key: "value", label: "Sales" }],
data: (c?.topItems ?? []).map((p) => ({ item: p.label, value: p.value })),
},
{
id: "sales-by-category",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Sales by Category",
description: "Share of sales value in the period",
kind: "donut",
data: slices(c?.salesByCategory ?? []),
},
{
id: "sales-by-customer",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Sales by Customer (Top 5)",
description: "By invoice value in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "customer",
series: [{ key: "value", label: "Sales" }],
data: (c?.salesByCustomer ?? []).map((p) => ({ customer: p.label, value: p.value })),
},
{
id: "document-status",
access: { any: ["dashboard.sales", "dashboard.procurement", "dashboard.accounts"] },
section: "Sales",
title: "Document Status Mix",
description: "Documents in the period by status",
kind: "bar",
stacked: true,
xKey: "doc",
series: [
{ key: "draft", label: "Draft" },
{ key: "posted", label: "Posted" },
{ key: "cancelled", label: "Cancelled" },
],
data: (c?.documentStatus ?? []).map((d) => ({ doc: d.doc, draft: d.draft, posted: d.posted, cancelled: d.cancelled })),
},
// ---------------------------------------------------------------- Money
{
id: "cash-in-hand",
access: { all: ["dashboard.accounts"] },
section: "Money",
title: "Cash in Hand by Day",
description: "Closing till cash each day (LKR)",
kind: "line",
area: true,
xKey: "day",
series: [{ key: "cash", label: "Closing cash" }],
data: (c?.cashInHandByDay ?? []).map((p) => ({ day: p.label, cash: p.value })),
},
{
id: "receivables-ageing",
access: { all: ["dashboard.sales"] },
section: "Money",
title: "Receivables Ageing",
description: "Unpaid sales invoices by age (LKR)",
kind: "bar",
xKey: "bucket",
series: [{ key: "amount", label: "Outstanding" }],
data: (c?.receivablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
},
{
id: "payables-ageing",
access: { all: ["dashboard.procurement"] },
section: "Money",
title: "Payables Ageing",
description: "Unpaid purchase invoices by age (LKR)",
kind: "bar",
xKey: "bucket",
series: [{ key: "amount", label: "Outstanding" }],
data: (c?.payablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
},
{
id: "expense-breakdown",
access: { all: ["dashboard.accounts"] },
section: "Money",
title: "Expense Breakdown by Category",
description: "Share of posted expenses in the period",
kind: "donut",
data: slices(c?.expenseBreakdown ?? []),
},
// ---------------------------------------------------------------- Purchasing
{
id: "top-vendors",
access: { all: ["dashboard.procurement"] },
section: "Purchasing",
title: "Top Vendors by Purchase Value",
description: "Posted purchase invoices in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "vendor",
series: [{ key: "value", label: "Purchases" }],
data: (c?.topVendors ?? []).map((p) => ({ vendor: p.label, value: p.value })),
},
// ---------------------------------------------------------------- Stock
{
id: "stock-value-share",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Stock Value Share by Warehouse",
description: "Share of total stock value",
kind: "donut",
data: slices(
(stats?.stockValuationByWarehouse ?? []).map((w) => ({
label: warehouseCodes.get(w.warehouseId) ?? `#${w.warehouseId}`,
value: w.total,
}))
),
},
{
id: "in-vs-out",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Stock In vs Out by Warehouse",
description: "Quantity moved in the period",
kind: "bar",
xKey: "warehouse",
series: [
{ key: "in", label: "In" },
{ key: "out", label: "Out" },
],
data: (c?.stockInVsOut ?? []).map((p) => ({ warehouse: p.label, in: p.first, out: p.second })),
},
{
id: "batches-expiring",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Batches Expiring Soon",
description: "Batches by days until expiry",
kind: "bar",
xKey: "window",
series: [{ key: "batches", label: "Batches" }],
data: (c?.batchesExpiring ?? []).map((p) => ({ window: p.label, batches: p.value })),
},
// ---------------------------------------------------------------- HR
{
id: "attendance-mix",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Attendance Status Mix",
description: "Attendance records in the period by status",
kind: "donut",
data: slices(c?.attendanceMix ?? []),
},
{
id: "payroll-cost",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Payroll Cost by Month",
description: "Net salary of approved payroll runs, last 12 months (LKR)",
kind: "bar",
xKey: "month",
series: [{ key: "cost", label: "Payroll" }],
data: (c?.payrollCostByMonth ?? []).map((p) => ({ month: p.label, cost: p.value })),
},
{
id: "leave-status",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Leave Requests by Status",
description: "Requests raised in the period",
kind: "donut",
data: slices(c?.leaveStatus ?? []),
},
// ---------------------------------------------------------------- Service & Production
{
id: "tickets-over-time",
access: { all: ["dashboard.service"] },
section: "Service & Production",
title: "Support Tickets: Opened vs Resolved",
description: "Per day over the selected period",
kind: "line",
xKey: "day",
series: [
{ key: "opened", label: "Opened" },
{ key: "resolved", label: "Resolved" },
],
data: (c?.ticketsOverTime ?? []).map((p) => ({ day: p.label, opened: p.first, resolved: p.second })),
},
{
id: "production-status",
access: { all: ["dashboard.production"] },
section: "Service & Production",
title: "Production Runs by Status",
description: "Runs started in the period",
kind: "donut",
data: slices(c?.productionStatus ?? []),
},
{
id: "warranty-expiry",
access: { all: ["dashboard.service"] },
section: "Service & Production",
title: "Warranties Expiring by Month",
description: "Active warranty registrations by end month, next 6 months",
kind: "bar",
xKey: "month",
series: [{ key: "warranties", label: "Warranties" }],
data: (c?.warrantyExpiryByMonth ?? []).map((p) => ({ month: p.label, warranties: p.value })),
},
]
}
+372
View File
@@ -0,0 +1,372 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}