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