Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports/dashboard")]
|
||||||
|
public class DashboardReportController(DashboardReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("sales")]
|
||||||
|
public async Task<IActionResult> Sales(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||||
|
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
|
||||||
|
{
|
||||||
|
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
|
||||||
|
var error = DashboardReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("inventory")]
|
||||||
|
public async Task<IActionResult> Inventory(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
|
||||||
|
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
|
||||||
|
{
|
||||||
|
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
|
||||||
|
var error = DashboardReportService.Validate(f);
|
||||||
|
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
|
||||||
|
|
||||||
|
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||||
|
|
||||||
|
public record SalesKpis(
|
||||||
|
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||||
|
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||||
|
|
||||||
|
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||||
|
|
||||||
|
public record TrendPoint(string Period, decimal Sales, decimal Returns);
|
||||||
|
|
||||||
|
public record NamedAmount(Guid? Id, string Name, decimal Amount);
|
||||||
|
|
||||||
|
public record SalesSummaryResponse(
|
||||||
|
DateTime From, DateTime To, string Granularity,
|
||||||
|
SalesKpiComparison Kpis, List<TrendPoint> Trend,
|
||||||
|
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||||
|
|
||||||
|
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||||
|
|
||||||
|
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
|
||||||
|
|
||||||
|
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
|
||||||
|
|
||||||
|
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
|
||||||
|
|
||||||
|
public record InventorySummaryResponse(
|
||||||
|
DateTime From, DateTime To, string Granularity,
|
||||||
|
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Distribution.Data;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
|
||||||
|
{
|
||||||
|
private sealed class SoldLine
|
||||||
|
{
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public Guid PoId { get; set; }
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public decimal? Cost { get; set; }
|
||||||
|
public Guid CategoryId { get; set; }
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public Guid RegionId { get; set; }
|
||||||
|
public string RegionName { get; set; } = "";
|
||||||
|
public Guid RepId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
|
||||||
|
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||||
|
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q =
|
||||||
|
from li in db.ShopPOItems.AsNoTracking()
|
||||||
|
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
|
||||||
|
where inv.Status == InvoiceStatus.Finalized
|
||||||
|
&& li.ShopPO.Status != ShopPOStatus.Cancelled
|
||||||
|
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
|
||||||
|
select new SoldLine
|
||||||
|
{
|
||||||
|
Date = inv.FinalizedAt!.Value.Date,
|
||||||
|
PoId = li.FK_ShopPOId,
|
||||||
|
Value = li.QuantityFulfilled * li.UnitPrice,
|
||||||
|
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
|
||||||
|
CategoryId = li.Item.FK_CategoryId,
|
||||||
|
CategoryName = li.Item.Category.Name,
|
||||||
|
RegionId = li.ShopPO.Store.Route.FK_RegionId,
|
||||||
|
RegionName = li.ShopPO.Store.Route.Region.Name,
|
||||||
|
RepId = li.ShopPO.RaisedById,
|
||||||
|
};
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.SaleReturnItems.AsNoTracking()
|
||||||
|
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
||||||
|
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var lines = SoldLines(f);
|
||||||
|
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
|
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
|
||||||
|
var costed = lines.Where(l => l.Cost != null);
|
||||||
|
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
|
||||||
|
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
|
||||||
|
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
|
||||||
|
return new SalesTotals(gross, returns, orders, costedSales, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => l.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
|
||||||
|
.ToListAsync())
|
||||||
|
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
|
||||||
|
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
|
||||||
|
.ToListAsync())
|
||||||
|
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
|
||||||
|
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||||
|
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
|
||||||
|
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
|
||||||
|
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).ToListAsync())
|
||||||
|
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var rows = await SoldLines(f).GroupBy(l => l.RepId)
|
||||||
|
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
|
||||||
|
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
|
||||||
|
var ids = rows.Select(r => r.RepId).ToList();
|
||||||
|
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.FullName);
|
||||||
|
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<StockBalance> Balances(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var q = db.StockBalances.AsNoTracking().AsQueryable();
|
||||||
|
if (f.WarehouseId.HasValue)
|
||||||
|
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value = quantity x cost price; items without a cost price contribute quantity only.
|
||||||
|
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
|
||||||
|
(await Balances(f).GroupBy(b => b.Item.Category.Name)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Category = g.Key,
|
||||||
|
Quantity = g.Sum(x => x.Quantity),
|
||||||
|
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Value).ToListAsync())
|
||||||
|
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
|
||||||
|
|
||||||
|
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
|
||||||
|
.GroupBy(b => b.LocationId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Id = g.Key,
|
||||||
|
Quantity = g.Sum(x => x.Quantity),
|
||||||
|
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
var ids = rows.Select(r => r.Id).ToList();
|
||||||
|
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
|
||||||
|
.ToDictionaryAsync(w => w.Id, w => w.Name);
|
||||||
|
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
|
||||||
|
.OrderByDescending(r => r.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
|
||||||
|
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
|
||||||
|
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var start = f.From.Date;
|
||||||
|
var end = f.To.Date.AddDays(1);
|
||||||
|
var q = db.StockMovements.AsNoTracking()
|
||||||
|
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
|
||||||
|
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|
||||||
|
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
|
||||||
|
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
|
||||||
|
if (f.WarehouseId.HasValue)
|
||||||
|
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
|
||||||
|
|
||||||
|
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
|
||||||
|
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
|
||||||
|
.ToListAsync();
|
||||||
|
return rows.GroupBy(r => r.Date)
|
||||||
|
.Select(g => (g.Key,
|
||||||
|
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
|
||||||
|
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public class DashboardReportService(IDashboardReportRepository repo)
|
||||||
|
{
|
||||||
|
public const int MaxRangeDays = 366;
|
||||||
|
private const int DailyGranularityMaxDays = 62;
|
||||||
|
|
||||||
|
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||||
|
{
|
||||||
|
var end = (to ?? DateTime.Now).Date;
|
||||||
|
var start = (from ?? end.AddDays(-29)).Date;
|
||||||
|
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Validate(DashboardFilter f)
|
||||||
|
{
|
||||||
|
if (f.To < f.From) return "'to' must not be before 'from'.";
|
||||||
|
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
|
||||||
|
|
||||||
|
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
|
||||||
|
|
||||||
|
private static SalesKpis ToKpis(SalesTotals t)
|
||||||
|
{
|
||||||
|
var net = t.GrossSales - t.ReturnValue;
|
||||||
|
var gp = t.CostedSales - t.CostOfSales;
|
||||||
|
return new SalesKpis(
|
||||||
|
t.GrossSales, t.ReturnValue, net, t.Orders,
|
||||||
|
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
|
||||||
|
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var days = (f.To - f.From).Days + 1;
|
||||||
|
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
|
||||||
|
var daily = Daily(f);
|
||||||
|
|
||||||
|
var current = await repo.GetSalesTotalsAsync(f);
|
||||||
|
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||||
|
var sales = await repo.GetDailySalesAsync(f);
|
||||||
|
var returns = await repo.GetDailyReturnsAsync(f);
|
||||||
|
|
||||||
|
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
|
||||||
|
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
|
||||||
|
.GroupBy(x => Bucket(x.Date, daily))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
|
||||||
|
return new SalesSummaryResponse(
|
||||||
|
f.From, f.To, daily ? "day" : "month",
|
||||||
|
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||||
|
trend,
|
||||||
|
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||||
|
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||||
|
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
|
||||||
|
{
|
||||||
|
var daily = Daily(f);
|
||||||
|
var movements = (await repo.GetDailyMovementsAsync(f))
|
||||||
|
.GroupBy(m => Bucket(m.Date, daily))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var rec = g.Sum(x => x.Received);
|
||||||
|
var iss = g.Sum(x => x.Issued);
|
||||||
|
return new MovementPoint(g.Key, rec, iss, rec - iss);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new InventorySummaryResponse(
|
||||||
|
f.From, f.To, daily ? "day" : "month",
|
||||||
|
await repo.GetStockValueByCategoryAsync(f),
|
||||||
|
await repo.GetStockByWarehouseAsync(f),
|
||||||
|
movements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Distribution.Features.DashboardReport;
|
||||||
|
|
||||||
|
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||||
|
|
||||||
|
public interface IDashboardReportRepository
|
||||||
|
{
|
||||||
|
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||||
|
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||||
|
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
|
||||||
|
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
|
||||||
|
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
|
||||||
|
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
|
||||||
|
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ using Distribution.Features.Sync;
|
|||||||
using Distribution.Features.Uom;
|
using Distribution.Features.Uom;
|
||||||
using Distribution.Features.User;
|
using Distribution.Features.User;
|
||||||
using Distribution.Features.Vehicle;
|
using Distribution.Features.Vehicle;
|
||||||
|
using Distribution.Features.DashboardReport;
|
||||||
using Distribution.Features.ProductReport;
|
using Distribution.Features.ProductReport;
|
||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Features.StockReport;
|
using Distribution.Features.StockReport;
|
||||||
@@ -75,6 +76,8 @@ builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
|||||||
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
builder.Services.AddScoped<IStockRepository, StockRepository>();
|
||||||
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||||
builder.Services.AddScoped<ProductReportService>();
|
builder.Services.AddScoped<ProductReportService>();
|
||||||
|
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
||||||
|
builder.Services.AddScoped<DashboardReportService>();
|
||||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||||
builder.Services.AddScoped<StockReportService>();
|
builder.Services.AddScoped<StockReportService>();
|
||||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||||
|
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from: string // yyyy-MM-dd
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
const fmtNum = (v: number) => v.toLocaleString('en-LK', { maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
function ChartCard({ title, note, children, className = '' }: { title: string; note?: string; children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex h-full flex-col rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md ${className}`}>
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
|
<span className="h-3.5 w-1 rounded-full bg-primary" />
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{note && <div className="mt-0.5 text-[11px] text-muted-foreground">{note}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-52 min-h-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Delta({ current, previous }: { current: number; previous: number }) {
|
||||||
|
if (previous === 0) return <span className="text-[10px] text-muted-foreground">No previous-period data</span>
|
||||||
|
const pct = ((current - previous) / Math.abs(previous)) * 100
|
||||||
|
const up = pct >= 0
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${up ? 'bg-foreground/10 text-foreground' : 'bg-destructive/10 text-destructive'}`}>
|
||||||
|
{up ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">vs prev.</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kpi({ title, value, footer }: { title: string; value: string; footer?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
|
||||||
|
<div>
|
||||||
|
<div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
|
||||||
|
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
{footer && <div className="truncate text-[10px] leading-tight">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingGrid() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3" aria-busy="true">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={`h-[262px] rounded-xl ${i % 3 === 0 ? 'md:col-span-2' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLoad<T>(load: () => Promise<T>, deps: unknown[], failTitle: string) {
|
||||||
|
const key = JSON.stringify(deps)
|
||||||
|
const [result, setResult] = useState<{ key: string; data: T | null } | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
load()
|
||||||
|
.then(data => { if (!cancelled) setResult({ key, data }) })
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return
|
||||||
|
toast.add({ title: failTitle, description: getApiError(err), type: 'error' })
|
||||||
|
setResult({ key, data: null })
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key])
|
||||||
|
// Loading until the response for the *current* deps has arrived, so a changed range shows the skeleton again.
|
||||||
|
return { data: result?.key === key ? result.data : null, loading: result?.key !== key }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ text }: { text: string }) {
|
||||||
|
return <p className="py-6 text-center text-xs text-muted-foreground">{text}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranked list with an inline share bar — friendlier than a bar chart for "top N" comparisons. */
|
||||||
|
function RankTable({ rows, fmt, empty, barClass = 'bg-primary' }: {
|
||||||
|
rows: Array<{ name: string; value: number; extra?: string }>
|
||||||
|
fmt: (v: number) => string
|
||||||
|
empty: string
|
||||||
|
barClass?: string
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0 || rows.every(r => r.value === 0)) return <Empty text={empty} />
|
||||||
|
const max = Math.max(...rows.map(r => r.value), 1)
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto pr-1">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={`${r.name}-${i}`} className="border-b border-muted last:border-0">
|
||||||
|
<td className="w-5 py-1.5 text-[10px] font-semibold text-muted-foreground">{i + 1}</td>
|
||||||
|
<td className="max-w-0 truncate py-1.5 pr-2 font-medium text-foreground" title={r.name}>
|
||||||
|
<div className="truncate">{r.name}</div>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={`h-full rounded-full ${barClass}`} style={{ width: `${(r.value / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-1.5 pl-1 text-right font-semibold tabular-nums text-foreground">
|
||||||
|
{fmt(r.value)}
|
||||||
|
{r.extra && <div className="text-[10px] font-normal text-muted-foreground">{r.extra}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({ cols, rows, empty }: { cols: string[]; rows: ReactNode[][]; empty: string }) {
|
||||||
|
if (rows.length === 0) return <Empty text={empty} />
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="border-b text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{cols.map((c, i) => <th key={c} className={`py-1.5 font-semibold ${i === 0 ? 'text-left' : 'text-right'}`}>{c}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, ri) => (
|
||||||
|
<tr key={ri} className="border-b border-muted last:border-0 hover:bg-muted/40">
|
||||||
|
{r.map((cell, ci) => (
|
||||||
|
<td key={ci} className={`py-1.5 tabular-nums ${ci === 0 ? 'max-w-0 truncate pr-2 text-left font-medium text-foreground' : 'text-right text-muted-foreground'}`}>{cell}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const named = (rows: Array<{ name: string; amount: number }>) => rows.map(r => ({ name: r.name, value: r.amount }))
|
||||||
|
|
||||||
|
/** Top (n-1) slices plus an "Other" slice, so donuts stay readable. */
|
||||||
|
function topWithOther(rows: Array<{ name: string; value: number }>, n = 5) {
|
||||||
|
const sorted = [...rows].filter(r => r.value > 0).sort((a, b) => b.value - a.value)
|
||||||
|
if (sorted.length <= n) return sorted
|
||||||
|
const head = sorted.slice(0, n - 1)
|
||||||
|
return [...head, { name: 'Other', value: sorted.slice(n - 1).reduce((t, r) => t + r.value, 0) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharePct = (v: number, total: number) => (total > 0 ? `${((v / total) * 100).toFixed(1)}%` : '—')
|
||||||
|
|
||||||
|
export function SalesSection({ range }: { range: DateRange }) {
|
||||||
|
const { data: sales, loading } = useLoad<DashboardSalesSummary>(
|
||||||
|
() => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics')
|
||||||
|
const { data: products, loading: productsLoading } = useLoad<ProductSalesRow[]>(
|
||||||
|
() => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)),
|
||||||
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
|
const { current: c, previous: p } = sales.kpis
|
||||||
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
|
<Kpi
|
||||||
|
title={title}
|
||||||
|
value={money ? fmtMoney(c[key] as number) : fmtNum(c[key] as number)}
|
||||||
|
footer={<Delta current={c[key] as number} previous={p[key] as number} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||||
|
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
||||||
|
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{kpi('Net Sales', 'netSales')}
|
||||||
|
{kpi('Gross Sales', 'grossSales')}
|
||||||
|
{kpi('Total Orders', 'orders', false)}
|
||||||
|
{kpi('Average Order Value', 'averageOrderValue')}
|
||||||
|
<Kpi
|
||||||
|
title="Gross Profit"
|
||||||
|
value={fmtMoney(c.grossProfit)}
|
||||||
|
footer={
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{c.grossProfitMarginPercent === null ? 'Margin n/a (no cost prices)' : `${c.grossProfitMarginPercent}% margin`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{kpi('Sales Returns', 'returnValue')}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
||||||
|
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||||
|
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Top-Selling Products" note="Top 10 by sales value" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Product', 'Qty', 'Sales', 'Margin']}
|
||||||
|
empty="No product sales in this period."
|
||||||
|
rows={(products ?? []).map(r => [
|
||||||
|
r.name,
|
||||||
|
fmtNum(r.qtyFulfilled),
|
||||||
|
fmtMoney(r.salesValue),
|
||||||
|
r.marginPercent === null ? '—' : (
|
||||||
|
<span className={r.marginPercent >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}>{r.marginPercent}%</span>
|
||||||
|
),
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Region">
|
||||||
|
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart
|
||||||
|
data={trend}
|
||||||
|
series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }, { key: 'returns', label: 'Returns', color: '#f87171' }]}
|
||||||
|
emptyText="No sales or returns in this period."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Sales by Representative" note="Top 20; rep = user who raised the order">
|
||||||
|
<RankTable rows={sales.byRep.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, repTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventorySection({ range }: { range: DateRange }) {
|
||||||
|
const { data: inv, loading } = useLoad<DashboardInventorySummary>(
|
||||||
|
() => dashboardReports.inventory(range), [range.from, range.to], 'Failed to load inventory analytics')
|
||||||
|
const { data: overview } = useLoad<StockOverview>(
|
||||||
|
() => stockReports.overview(), [], 'Failed to load stock overview')
|
||||||
|
const { data: lowStock } = useLoad(
|
||||||
|
() => productReports.stock({ lowStockOnly: true, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
|
[], 'Failed to load low-stock products')
|
||||||
|
const { data: movers, loading: moversLoading } = useLoad<ProductSalesRow[]>(
|
||||||
|
() => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
|
if (loading || moversLoading || !inv || !overview) return <LoadingGrid />
|
||||||
|
|
||||||
|
const healthy = Math.max(0, overview.totalSkus - overview.lowStockCount - overview.outOfStockCount)
|
||||||
|
const sold = (movers ?? []).map(r => ({ name: r.name, value: r.qtyFulfilled }))
|
||||||
|
const fast = sold.slice(0, 3)
|
||||||
|
const slow = sold.slice(-3).reverse()
|
||||||
|
const catTotal = inv.valueByCategory.reduce((t, r) => t + r.value, 0)
|
||||||
|
const lowRows = (lowStock ?? [])
|
||||||
|
.filter(r => r.reorderLevel)
|
||||||
|
.sort((a, b) => a.warehouseQty / (a.reorderLevel ?? 1) - b.warehouseQty / (b.reorderLevel ?? 1))
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
<Kpi title="Active Products" value={fmtNum(overview.totalSkus)} />
|
||||||
|
<Kpi title="Stock on Hand" value={fmtNum(overview.totalQty)} />
|
||||||
|
<Kpi title="Inventory Value" value={fmtMoney(overview.totalValue)} footer={<span className="text-[10px] text-muted-foreground">At cost price</span>} />
|
||||||
|
<Kpi title="Low-Stock Products" value={fmtNum(overview.lowStockCount)} />
|
||||||
|
<Kpi title="Out-of-Stock Products" value={fmtNum(overview.outOfStockCount)} />
|
||||||
|
<Kpi title="Expiring Soon (30 days)" value={fmtNum(overview.expiringSoonCount)} footer={<span className="text-[10px] text-muted-foreground">Batches · {fmtNum(overview.expiredCount)} already expired</span>} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Reserved stock, excess stock, ageing, discrepancies and pending transfers are not tracked yet, so they are not shown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<ChartCard title="Stock Movement Trend" note={`Quantity per ${inv.granularity}. Received = purchases + customer returns; issued = sales + purchase returns + damages`} className="md:col-span-2">
|
||||||
|
<MultiAreaChart
|
||||||
|
data={inv.movementTrend}
|
||||||
|
series={[{ key: 'received', label: 'Received', color: seriesColor(0) }, { key: 'issued', label: 'Issued', color: seriesColor(1) }]}
|
||||||
|
emptyText="No stock movements in this period."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Stock Health" note="Active products">
|
||||||
|
<DonutChart
|
||||||
|
data={[
|
||||||
|
{ name: 'Healthy', value: healthy },
|
||||||
|
{ name: 'Low stock', value: overview.lowStockCount },
|
||||||
|
{ name: 'Out of stock', value: overview.outOfStockCount },
|
||||||
|
]}
|
||||||
|
centerLabel="products"
|
||||||
|
emptyText="No active products."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Warehouse Stock" note="Quantity and value at cost price" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Warehouse', 'Quantity', 'Value']}
|
||||||
|
empty="No warehouse stock recorded."
|
||||||
|
rows={inv.byWarehouse.map(r => [r.name, fmtNum(r.quantity), fmtMoney(r.value)])}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Inventory Value by Category" note="At cost price">
|
||||||
|
<RankTable
|
||||||
|
rows={inv.valueByCategory.map(r => ({ name: r.category, value: r.value, extra: sharePct(r.value, catTotal) }))}
|
||||||
|
fmt={fmtMoney} empty="No stock recorded."
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Low-Stock Products" note="Lowest coverage of reorder level first" className="md:col-span-2">
|
||||||
|
<DataTable
|
||||||
|
cols={['Product', 'On hand', 'Reorder at', 'Coverage']}
|
||||||
|
empty="No products below their reorder level."
|
||||||
|
rows={lowRows.map(r => {
|
||||||
|
const cov = Math.round((r.warehouseQty / (r.reorderLevel ?? 1)) * 100)
|
||||||
|
return [
|
||||||
|
r.name,
|
||||||
|
fmtNum(r.warehouseQty),
|
||||||
|
fmtNum(r.reorderLevel ?? 0),
|
||||||
|
<span key="c" className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${cov < 50 ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'}`}>{cov}%</span>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title="Fast & Slow Movers" note="Top and bottom 3 by quantity sold (among top 50 sellers)">
|
||||||
|
<div className="flex h-full flex-col gap-1 overflow-auto">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Fast-moving</div>
|
||||||
|
<RankTable rows={fast} fmt={fmtNum} empty="No sales in this period." />
|
||||||
|
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Slow-moving</div>
|
||||||
|
<RankTable rows={slow} fmt={fmtNum} empty="No sales in this period." />
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts'
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, Line, LineChart, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -60,10 +60,10 @@ export function DonutChart({ data, centerLabel, emptyText }: { data: ChartDatum[
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)' }: { data: ChartDatum[]; emptyText: string; color?: string }) {
|
export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)', label = 'Quantity' }: { data: ChartDatum[]; emptyText: string; color?: string; label?: string }) {
|
||||||
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
|
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
|
||||||
|
|
||||||
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig
|
const config = { value: { label, color } } satisfies ChartConfig
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={config} className="h-full min-h-40 w-full">
|
<ChartContainer config={config} className="h-full min-h-40 w-full">
|
||||||
@@ -140,3 +140,83 @@ export function RadialGauge({ pct, label, sub }: { pct: number; label: string; s
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SeriesDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)']
|
||||||
|
export const seriesColor = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length]
|
||||||
|
|
||||||
|
/** Multi-series line chart over a categorical x axis (day or month labels). */
|
||||||
|
export function MultiLineChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-48 w-full">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grouped (side-by-side) column chart comparing several series per category. */
|
||||||
|
export function GroupedColumnChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-48 w-full">
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={16} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Bar key={s.key} dataKey={s.key} fill={`var(--color-${s.key})`} radius={3} maxBarSize={24} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-series smooth area chart with a soft gradient under each line. */
|
||||||
|
export function MultiAreaChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
|
||||||
|
if (data.length === 0) return <Empty text={emptyText} />
|
||||||
|
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={config} className="h-full min-h-40 w-full">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
{series.map(s => (
|
||||||
|
<linearGradient key={s.key} id={`area-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.45} />
|
||||||
|
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.03} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} tick={{ fontSize: 10 }} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
{series.map(s => (
|
||||||
|
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} fill={`url(#area-${s.key})`} />
|
||||||
|
))}
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
|
||||||
import { Add01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
|
|
||||||
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
|
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
|
||||||
import type { Layout, LayoutItem } from 'react-grid-layout'
|
import type { LayoutItem } from 'react-grid-layout'
|
||||||
import 'react-grid-layout/css/styles.css'
|
import 'react-grid-layout/css/styles.css'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import {
|
import {
|
||||||
@@ -23,12 +19,12 @@ import {
|
|||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||||
|
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||||
|
|
||||||
const STORAGE_KEY = 'distribution-dashboard-widgets-v3'
|
const ROW_HEIGHT = 26
|
||||||
const ROW_HEIGHT = 36
|
const GRID_MARGIN: [number, number] = [10, 10]
|
||||||
const GRID_MARGIN: [number, number] = [16, 16]
|
|
||||||
|
|
||||||
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
|
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
|
||||||
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
|
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
|
||||||
@@ -83,14 +79,14 @@ const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW:
|
|||||||
},
|
},
|
||||||
md: {
|
md: {
|
||||||
sm: { w: 5, h: 4, minW: 3, minH: 3 },
|
sm: { w: 5, h: 4, minW: 3, minH: 3 },
|
||||||
md: { w: 10, h: 6, minW: 4, minH: 3 },
|
md: { w: 5, h: 6, minW: 3, minH: 3 },
|
||||||
lg: { w: 10, h: 7, minW: 4, minH: 4 },
|
lg: { w: 10, h: 7, minW: 4, minH: 4 },
|
||||||
chart: { w: 5, h: 7, minW: 3, minH: 5 },
|
chart: { w: 5, h: 7, minW: 3, minH: 5 },
|
||||||
chartWide: { w: 10, h: 7, minW: 4, minH: 5 },
|
chartWide: { w: 10, h: 7, minW: 4, minH: 5 },
|
||||||
},
|
},
|
||||||
sm: {
|
sm: {
|
||||||
sm: { w: 3, h: 4, minW: 2, minH: 3 },
|
sm: { w: 3, h: 4, minW: 2, minH: 3 },
|
||||||
md: { w: 6, h: 6, minW: 3, minH: 3 },
|
md: { w: 3, h: 6, minW: 2, minH: 3 },
|
||||||
lg: { w: 6, h: 7, minW: 3, minH: 4 },
|
lg: { w: 6, h: 7, minW: 3, minH: 4 },
|
||||||
chart: { w: 6, h: 7, minW: 3, minH: 5 },
|
chart: { w: 6, h: 7, minW: 3, minH: 5 },
|
||||||
chartWide: { w: 6, h: 7, minW: 3, minH: 5 },
|
chartWide: { w: 6, h: 7, minW: 3, minH: 5 },
|
||||||
@@ -133,54 +129,6 @@ function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFlowLayouts(ids: string[]): Record<Breakpoint, LayoutItem[]> {
|
|
||||||
return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record<Breakpoint, LayoutItem[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Places a newly-added widget below everything else on a given breakpoint, so it never overlaps what's already there. */
|
|
||||||
function appendWidget(layout: LayoutItem[], meta: WidgetMeta, bp: Breakpoint): LayoutItem[] {
|
|
||||||
const cfg = SIZE_CONFIG[bp][meta.size]
|
|
||||||
const y = layout.reduce((max, item) => Math.max(max, item.y + item.h), 0)
|
|
||||||
return [...layout, { i: meta.id, x: 0, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH }]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardLayoutState {
|
|
||||||
activeIds: string[]
|
|
||||||
layouts: Record<Breakpoint, LayoutItem[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultLayoutState(): DashboardLayoutState {
|
|
||||||
return { activeIds: [...DEFAULT_ACTIVE], layouts: generateFlowLayouts(DEFAULT_ACTIVE) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStoredLayoutState(): DashboardLayoutState {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return defaultLayoutState()
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
const validIds = new Set(WIDGET_META.map(w => w.id))
|
|
||||||
const activeIds: string[] = Array.isArray(parsed?.activeIds)
|
|
||||||
? parsed.activeIds.filter((id: unknown): id is string => typeof id === 'string' && validIds.has(id))
|
|
||||||
: []
|
|
||||||
if (activeIds.length === 0) return defaultLayoutState()
|
|
||||||
|
|
||||||
const layouts = {} as Record<Breakpoint, LayoutItem[]>
|
|
||||||
for (const bp of BREAKPOINT_ORDER) {
|
|
||||||
const storedLayout: LayoutItem[] = Array.isArray(parsed?.layouts?.[bp])
|
|
||||||
? parsed.layouts[bp].filter((l: unknown): l is LayoutItem =>
|
|
||||||
!!l && typeof l === 'object' && typeof (l as LayoutItem).i === 'string' && validIds.has((l as LayoutItem).i) && activeIds.includes((l as LayoutItem).i))
|
|
||||||
: []
|
|
||||||
const missingIds = activeIds.filter(id => !storedLayout.some(l => l.i === id))
|
|
||||||
layouts[bp] = missingIds.length > 0 ? [...storedLayout, ...generateFlowLayout(missingIds, bp)] : storedLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeIds, layouts }
|
|
||||||
} catch {
|
|
||||||
return defaultLayoutState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
|
||||||
|
|
||||||
interface OpenRide {
|
interface OpenRide {
|
||||||
@@ -351,12 +299,51 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryPill({ cat }: { cat: Category }) {
|
const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus']
|
||||||
const meta = CATEGORY_META[cat]
|
|
||||||
|
function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
|
<div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
|
||||||
{meta.label}
|
<div>
|
||||||
</span>
|
<div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
|
||||||
|
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{sub && <div className="truncate text-[10px] text-muted-foreground">{sub}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatStrip({ ids, data }: { ids: string[]; data: DashboardData }) {
|
||||||
|
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
|
||||||
|
const invTotal = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
|
||||||
|
const seg = (n: number) => (invTotal > 0 ? `${(n / invTotal) * 100}%` : '0%')
|
||||||
|
const tiles: Record<string, React.ReactNode> = {
|
||||||
|
pendingPO: <StatTile title="Pending Shop POs" value={String(data.pendingPOCount)} sub={`${data.totalPOCount} total orders`} />,
|
||||||
|
awaitingConfirm: <StatTile title="Awaiting Confirmation" value={String(data.awaitingConfirmCount)} sub="Confirmed, not yet loaded" />,
|
||||||
|
fulfillRate: (
|
||||||
|
<StatTile title="Fulfillment Rate" value={`${pct}%`} sub={`${data.fulfilledPOCount} of ${data.totalPOCount} fulfilled`}>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} /></div>
|
||||||
|
</StatTile>
|
||||||
|
),
|
||||||
|
deliveryTasks: <StatTile title="Pending Delivery Tasks" value={String(data.pendingTasksCount)} sub={`${data.partialTasksCount} partially delivered`} />,
|
||||||
|
outstandingPay: <StatTile title="Outstanding Payments" value={fmtMoney(data.outstandingAmount)} sub={`${data.outstandingInvoiceCount} invoices`} />,
|
||||||
|
invoiceStatus: (
|
||||||
|
<StatTile title="Invoices by Status" value={String(invTotal)} sub={`${data.draftInvoiceCount} draft · ${data.printedInvoiceCount} printed · ${data.finalizedInvoiceCount} final`}>
|
||||||
|
<div className="mt-1 flex h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="bg-primary/30" style={{ width: seg(data.draftInvoiceCount) }} />
|
||||||
|
<div className="bg-primary/60" style={{ width: seg(data.printedInvoiceCount) }} />
|
||||||
|
<div className="bg-primary" style={{ width: seg(data.finalizedInvoiceCount) }} />
|
||||||
|
</div>
|
||||||
|
</StatTile>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{STAT_IDS.filter(id => ids.includes(id)).map(id => <div key={id}>{tiles[id]}</div>)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,14 +352,14 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
case 'pendingPO':
|
case 'pendingPO':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.pendingPOCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.pendingPOCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'awaitingConfirm':
|
case 'awaitingConfirm':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -391,7 +378,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
|
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
|
||||||
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
|
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-violet-600 px-2.5 py-1 text-[11px] font-semibold text-white">Open</span>
|
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300">Open</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +396,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
case 'deliveryTasks':
|
case 'deliveryTasks':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{data.pendingTasksCount}</div>
|
<div className="text-2xl font-bold text-foreground">{data.pendingTasksCount}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -454,7 +441,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
case 'outstandingPay':
|
case 'outstandingPay':
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
|
<div className="text-2xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
|
||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -496,30 +483,43 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeLayoutsChange(
|
function toIsoDate(d: Date) {
|
||||||
full: Record<Breakpoint, LayoutItem[]>,
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
changed: Partial<Record<Breakpoint, Layout>>,
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
): Record<Breakpoint, LayoutItem[]> {
|
}
|
||||||
const next = { ...full }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) {
|
type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom'
|
||||||
const changedArr = changed[bp]
|
|
||||||
if (!changedArr) continue
|
const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [
|
||||||
next[bp] = full[bp].map(item => {
|
{ key: '7d', label: 'Last 7 days' },
|
||||||
const updated = changedArr.find(c => c.i === item.i)
|
{ key: '1m', label: '1 month' },
|
||||||
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
|
{ key: '3m', label: '3 months' },
|
||||||
})
|
{ key: '1y', label: '1 year' },
|
||||||
}
|
{ key: 'custom', label: 'Custom' },
|
||||||
return next
|
]
|
||||||
|
|
||||||
|
function presetRange(preset: Exclude<RangePreset, 'custom'>): DateRange {
|
||||||
|
const to = new Date()
|
||||||
|
const from = new Date()
|
||||||
|
if (preset === '7d') from.setDate(from.getDate() - 6)
|
||||||
|
else if (preset === '1m') from.setMonth(from.getMonth() - 1)
|
||||||
|
else if (preset === '3m') from.setMonth(from.getMonth() - 3)
|
||||||
|
else { from.setFullYear(from.getFullYear() - 1); from.setDate(from.getDate() + 1) }
|
||||||
|
return { from: toIsoDate(from), to: toIsoDate(to) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function minFromDate(to: string) {
|
||||||
|
const d = new Date(`${to}T00:00:00`)
|
||||||
|
d.setDate(d.getDate() - 365)
|
||||||
|
return toIsoDate(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardClient() {
|
export function DashboardClient() {
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
const [data, setData] = useState<DashboardData | null>(null)
|
const [data, setData] = useState<DashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
|
const [range, setRange] = useState<DateRange>(() => presetRange('1m'))
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [preset, setPreset] = useState<RangePreset>('1m')
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
|
||||||
const [category, setCategory] = useState<'all' | Category>('all')
|
|
||||||
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
|
||||||
|
|
||||||
const allowedIds = useMemo(
|
const allowedIds = useMemo(
|
||||||
@@ -528,23 +528,10 @@ export function DashboardClient() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const permittedActiveIds = useMemo(
|
const permittedActiveIds = useMemo(
|
||||||
() => state.activeIds.filter(id => allowedIds.has(id)),
|
() => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)),
|
||||||
[state.activeIds, allowedIds],
|
[allowedIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setState(loadStoredLayoutState())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const persist = useCallback((next: DashboardLayoutState) => {
|
|
||||||
setState(next)
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable (private browsing, etc.) — layout just won't persist
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -560,118 +547,82 @@ export function DashboardClient() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
function toggleWidget(id: string, checked: boolean) {
|
|
||||||
if (checked) {
|
|
||||||
const meta = WIDGET_META.find(w => w.id === id)
|
|
||||||
if (!meta || state.activeIds.includes(id)) return
|
|
||||||
const layouts = { ...state.layouts }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) layouts[bp] = appendWidget(layouts[bp], meta, bp)
|
|
||||||
persist({ activeIds: [...state.activeIds, id], layouts })
|
|
||||||
} else {
|
|
||||||
const layouts = { ...state.layouts }
|
|
||||||
for (const bp of BREAKPOINT_ORDER) layouts[bp] = layouts[bp].filter(l => l.i !== id)
|
|
||||||
persist({ activeIds: state.activeIds.filter(x => x !== id), layouts })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLayoutChange(_layout: Layout, changedLayouts: Partial<Record<Breakpoint, Layout>>) {
|
|
||||||
setState(prev => {
|
|
||||||
const next = { ...prev, layouts: mergeLayoutsChange(prev.layouts, changedLayouts) }
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — position just won't persist
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() => permittedActiveIds
|
() => permittedActiveIds
|
||||||
.map(id => WIDGET_META.find(w => w.id === id))
|
.map(id => WIDGET_META.find(w => w.id === id))
|
||||||
.filter((w): w is WidgetMeta => Boolean(w))
|
.filter((w): w is WidgetMeta => Boolean(w))
|
||||||
.filter(w => category === 'all' || w.cat === category),
|
.filter(w => !STAT_IDS.includes(w.id)),
|
||||||
[permittedActiveIds, category],
|
[permittedActiveIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleLayouts = useMemo(() => {
|
const visibleLayouts = useMemo(() => {
|
||||||
const visibleIds = new Set(visibleWidgets.map(w => w.id))
|
const visibleIds = new Set(visibleWidgets.map(w => w.id))
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
BREAKPOINT_ORDER.map(bp => [bp, state.layouts[bp].filter(l => visibleIds.has(l.i))]),
|
BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(Array.from(visibleIds), bp)]),
|
||||||
) as Record<Breakpoint, LayoutItem[]>
|
) as Record<Breakpoint, LayoutItem[]>
|
||||||
}, [state.layouts, visibleWidgets])
|
}, [visibleWidgets])
|
||||||
|
|
||||||
const filters: Array<{ key: 'all' | Category; label: string }> = [
|
const canSales = hasPermission('orders.view')
|
||||||
{ key: 'all', label: 'All widgets' },
|
const canInventory = hasPermission('stock.view')
|
||||||
{ key: 'sales', label: CATEGORY_META.sales.label },
|
|
||||||
{ key: 'ops', label: CATEGORY_META.ops.label },
|
|
||||||
{ key: 'stock', label: CATEGORY_META.stock.label },
|
|
||||||
{ key: 'finance', label: CATEGORY_META.finance.label },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
<h1 className="text-xl font-semibold text-foreground">Dashboard</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{editMode ? 'Drag to move, drag a corner to resize, or tick which widgets show.' : 'Add, remove, drag and resize widgets to make this view yours.'}
|
Sales, inventory, shop orders, rides and loading sheets, deliveries, stock alerts, invoices and payments
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-2">
|
|
||||||
{editMode && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowPicker(v => !v)}>
|
|
||||||
<HugeiconsIcon icon={Add01Icon} size={14} />
|
|
||||||
Widgets
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant={editMode ? 'default' : 'outline'} size="sm" onClick={() => { setEditMode(v => !v); setShowPicker(false) }}>
|
|
||||||
{editMode ? 'Done' : 'Customize Dashboard'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showPicker && (
|
|
||||||
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-2xl border bg-popover p-3 shadow-lg">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-semibold text-foreground">Widgets</span>
|
|
||||||
<button onClick={() => setShowPicker(false)} aria-label="Close" className="rounded-full bg-muted p-1 text-muted-foreground">
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => {
|
|
||||||
const checked = state.activeIds.includes(w.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={w.id}
|
|
||||||
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Checkbox checked={checked} onCheckedChange={(v) => toggleWidget(w.id, v === true)} />
|
|
||||||
<span className="text-sm text-foreground">{w.title}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{(canSales || canInventory) && (
|
||||||
{filters.map(f => (
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<span />
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<div className="inline-flex rounded-lg border bg-muted/50 p-0.5">
|
||||||
|
{RANGE_PRESETS.map(p => (
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
key={p.key}
|
||||||
onClick={() => setCategory(f.key)}
|
onClick={() => { setPreset(p.key); if (p.key !== 'custom') setRange(presetRange(p.key)) }}
|
||||||
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
category === f.key
|
preset === p.key ? 'bg-primary text-primary-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
||||||
? 'border-foreground bg-foreground text-background'
|
|
||||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{p.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="date" value={range.from} min={minFromDate(range.to)} max={range.to} aria-label="From date"
|
||||||
|
onChange={e => e.target.value && setRange(r => ({ ...r, from: e.target.value }))}
|
||||||
|
className="rounded-lg border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">to</span>
|
||||||
|
<input
|
||||||
|
type="date" value={range.to} min={range.from} max={toIsoDate(new Date())} aria-label="To date"
|
||||||
|
onChange={e => e.target.value && setRange(r => ({ ...r, to: e.target.value }))}
|
||||||
|
className="rounded-lg border bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canSales && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Sales</h3>
|
||||||
|
<SalesSection range={range} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canInventory && (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Inventory</h3>
|
||||||
|
<InventorySection range={range} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading || !data ? (
|
{loading || !data ? (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -679,11 +630,9 @@ export function DashboardClient() {
|
|||||||
<Skeleton key={i} className="h-40 rounded-2xl" />
|
<Skeleton key={i} className="h-40 rounded-2xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : visibleWidgets.length === 0 ? (
|
|
||||||
<div className="rounded-2xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
|
||||||
No widgets on this dashboard yet. Click “Widgets” to add some.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<StatStrip ids={permittedActiveIds} data={data} />
|
||||||
<div ref={containerRef} className="w-full">
|
<div ref={containerRef} className="w-full">
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<ResponsiveGridLayout
|
<ResponsiveGridLayout
|
||||||
@@ -695,25 +644,18 @@ export function DashboardClient() {
|
|||||||
margin={GRID_MARGIN}
|
margin={GRID_MARGIN}
|
||||||
containerPadding={[0, 0]}
|
containerPadding={[0, 0]}
|
||||||
maxRows={Infinity}
|
maxRows={Infinity}
|
||||||
dragConfig={{ enabled: editMode }}
|
dragConfig={{ enabled: false }}
|
||||||
resizeConfig={{ enabled: editMode, handles: ['se'] }}
|
resizeConfig={{ enabled: false }}
|
||||||
autoSize
|
autoSize
|
||||||
onLayoutChange={handleLayoutChange}
|
|
||||||
>
|
>
|
||||||
{visibleWidgets.map(w => (
|
{visibleWidgets.map(w => (
|
||||||
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
|
<div key={w.id} className="relative flex flex-col overflow-auto rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md">
|
||||||
{editMode && (
|
<div className="mb-2 shrink-0">
|
||||||
<button
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
onClick={() => toggleWidget(w.id, false)}
|
<span className="h-3.5 w-1 rounded-full bg-primary" />
|
||||||
aria-label="Remove widget"
|
{w.title}
|
||||||
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground"
|
</div>
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="mb-3.5 shrink-0">
|
|
||||||
<div className="text-sm font-semibold text-foreground">{w.title}</div>
|
|
||||||
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<WidgetBody id={w.id} data={data} />
|
<WidgetBody id={w.id} data={data} />
|
||||||
@@ -723,6 +665,7 @@ export function DashboardClient() {
|
|||||||
</ResponsiveGridLayout>
|
</ResponsiveGridLayout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
|
import { RoleForm } from '../../_components/role-form'
|
||||||
|
|
||||||
|
export default function EditRolePage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
const [role, setRole] = useState<RoleResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rolesApi.get(id)
|
||||||
|
.then(setRole)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load role', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Edit Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Update role details and permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && role && <RoleForm role={role} onSuccess={goBack} onCancel={goBack} wide />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
||||||
|
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
||||||
|
|
||||||
|
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
||||||
|
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
||||||
|
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
||||||
|
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
||||||
|
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
||||||
|
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
||||||
|
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
||||||
|
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
level: z.string().min(1, 'Level is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
// 'orders.view' -> 'View Orders', 'loading-sheet.manage' -> 'Manage Loading Sheet'
|
||||||
|
function permissionLabel(code: string): string {
|
||||||
|
const [resource, action] = code.split('.')
|
||||||
|
const title = (s: string) => s.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
return action ? `${title(action)} ${title(resource)}` : title(resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleFormProps {
|
||||||
|
role: RoleResponse | null
|
||||||
|
onSuccess: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
wide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleForm({ role, onSuccess, onCancel, wide = false }: RoleFormProps) {
|
||||||
|
const isEdit = !!role
|
||||||
|
|
||||||
|
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', level: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
||||||
|
if (role) {
|
||||||
|
form.reset({ name: role.name, level: String(role.level) })
|
||||||
|
rolesApi.getPermissions(role.id)
|
||||||
|
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
||||||
|
.catch(() => {})
|
||||||
|
} else {
|
||||||
|
form.reset({ name: '', level: '' })
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
function togglePermission(id: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const body = { name: values.name, level: parseInt(values.level, 10) }
|
||||||
|
try {
|
||||||
|
let roleId: string
|
||||||
|
if (isEdit) {
|
||||||
|
await rolesApi.update(role!.id, body)
|
||||||
|
roleId = role!.id
|
||||||
|
toast.add({ title: 'Role updated', type: 'success' })
|
||||||
|
} else {
|
||||||
|
const created = await rolesApi.create(body)
|
||||||
|
roleId = created.id
|
||||||
|
toast.add({ title: 'Role created', type: 'success' })
|
||||||
|
}
|
||||||
|
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup className={wide ? 'grid grid-cols-1 gap-6 md:grid-cols-2' : undefined}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Role Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Sales Rep"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="level"
|
||||||
|
label="Level"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={String(l.value)}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{l.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{l.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{allPermissions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Permissions</p>
|
||||||
|
<div className={wide ? 'border rounded-md p-4' : 'border rounded-md max-h-[28rem] overflow-y-auto p-3'}>
|
||||||
|
<div className={wide ? 'flex flex-col gap-3' :'grid grid-cols-2 gap-3'}>
|
||||||
|
{PERMISSION_CATEGORIES.map((cat) => {
|
||||||
|
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
||||||
|
if (catPerms.length === 0) return null
|
||||||
|
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
||||||
|
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`cat-${cat.id}`}
|
||||||
|
checked={allChecked}
|
||||||
|
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
||||||
|
{cat.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={wide ? 'flex flex-row flex-wrap gap-x-6 gap-y-2 pl-6' : 'flex flex-col gap-1.5 pl-6'}>
|
||||||
|
{catPerms.map((p) => (
|
||||||
|
<div key={p.permissionId} className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={p.permissionId}
|
||||||
|
checked={selectedIds.has(p.permissionId)}
|
||||||
|
onCheckedChange={() => togglePermission(p.permissionId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
||||||
|
<span className="font-medium">{wide ? permissionLabel(p.code) : p.code}</span>
|
||||||
|
{!wide && p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
|
||||||
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
|
||||||
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
|
||||||
|
|
||||||
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
|
||||||
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
|
||||||
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
|
||||||
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
|
||||||
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
|
||||||
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
|
||||||
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
|
||||||
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
level: z.string().min(1, 'Level is required'),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface RoleSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
role: RoleResponse | null
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoleSheet({ open, onClose, role, onSuccess }: RoleSheetProps) {
|
|
||||||
const isEdit = !!role
|
|
||||||
|
|
||||||
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: { name: '', level: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
|
||||||
if (role) {
|
|
||||||
form.reset({ name: role.name, level: String(role.level) })
|
|
||||||
rolesApi.getPermissions(role.id)
|
|
||||||
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
|
||||||
.catch(() => {})
|
|
||||||
} else {
|
|
||||||
form.reset({ name: '', level: '' })
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
}
|
|
||||||
}, [open, role])
|
|
||||||
|
|
||||||
function togglePermission(id: string) {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
const body = { name: values.name, level: parseInt(values.level, 10) }
|
|
||||||
try {
|
|
||||||
let roleId: string
|
|
||||||
if (isEdit) {
|
|
||||||
await rolesApi.update(role!.id, body)
|
|
||||||
roleId = role!.id
|
|
||||||
toast.add({ title: 'Role updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
const created = await rolesApi.create(body)
|
|
||||||
roleId = created.id
|
|
||||||
toast.add({ title: 'Role created', type: 'success' })
|
|
||||||
}
|
|
||||||
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? 'Edit Role' : 'Add Role'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit ? 'Update role details and permissions.' : 'Create a new role and assign permissions.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Role Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Sales Rep"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="level"
|
|
||||||
label="Level"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select level" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROLE_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={String(l.value)}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{l.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{l.description}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{allPermissions.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Permissions</p>
|
|
||||||
<div className="border rounded-md max-h-[28rem] overflow-y-auto p-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{PERMISSION_CATEGORIES.map((cat) => {
|
|
||||||
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
|
||||||
if (catPerms.length === 0) return null
|
|
||||||
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
|
||||||
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`cat-${cat.id}`}
|
|
||||||
checked={allChecked}
|
|
||||||
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
|
||||||
{cat.label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 pl-6">
|
|
||||||
{catPerms.map((p) => (
|
|
||||||
<div key={p.permissionId} className="flex items-start gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={p.permissionId}
|
|
||||||
checked={selectedIds.has(p.permissionId)}
|
|
||||||
onCheckedChange={() => togglePermission(p.permissionId)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
|
||||||
<span className="font-medium">{p.code}</span>
|
|
||||||
{p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -12,17 +13,15 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
import type { RoleResponse } from '@/types/api'
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RoleSheet } from './role-sheet'
|
|
||||||
import { RoleViewSheet } from './role-view-sheet'
|
import { RoleViewSheet } from './role-view-sheet'
|
||||||
|
|
||||||
export function RolesClient() {
|
export function RolesClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<RoleResponse[]>([])
|
const [data, setData] = useState<RoleResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<RoleResponse | null>(null)
|
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
||||||
|
|
||||||
@@ -41,8 +40,8 @@ export function RolesClient() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
function openCreate() { setEditing(null); setSheetOpen(true) }
|
function openCreate() { router.push('/users/roles/new') }
|
||||||
function openEdit(role: RoleResponse) { setEditing(role); setSheetOpen(true) }
|
function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) }
|
||||||
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
||||||
|
|
||||||
async function handleDeactivate(role: RoleResponse) {
|
async function handleDeactivate(role: RoleResponse) {
|
||||||
@@ -122,13 +121,6 @@ export function RolesClient() {
|
|||||||
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
role={editing}
|
|
||||||
onSuccess={() => fetchData()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RoleViewSheet
|
<RoleViewSheet
|
||||||
open={viewOpen}
|
open={viewOpen}
|
||||||
onClose={() => setViewOpen(false)}
|
onClose={() => setViewOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import { RoleForm } from '../_components/role-form'
|
||||||
|
|
||||||
|
export default function NewRolePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Add Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Create a new role and assign permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<RoleForm role={null} onSuccess={goBack} onCancel={goBack} wide />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
|
|||||||
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||||
export { stock } from './stock'
|
export { stock } from './stock'
|
||||||
export { productReports } from './reports'
|
export { productReports, stockReports, dashboardReports } from './reports'
|
||||||
export { shopPos, invoices, payments } from './sales'
|
export { shopPos, invoices, payments } from './sales'
|
||||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
||||||
|
|||||||
@@ -159,3 +159,41 @@ export const productReports = {
|
|||||||
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
|
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
|
||||||
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data),
|
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SalesKpis {
|
||||||
|
grossSales: number
|
||||||
|
returnValue: number
|
||||||
|
netSales: number
|
||||||
|
orders: number
|
||||||
|
averageOrderValue: number
|
||||||
|
grossProfit: number
|
||||||
|
grossProfitMarginPercent: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSalesSummary {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
granularity: 'day' | 'month'
|
||||||
|
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
|
||||||
|
trend: Array<{ period: string; sales: number; returns: number }>
|
||||||
|
byCategory: Array<{ id: string | null; name: string; amount: number }>
|
||||||
|
byRegion: Array<{ id: string | null; name: string; amount: number }>
|
||||||
|
byRep: Array<{ id: string | null; name: string; amount: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardInventorySummary {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
granularity: 'day' | 'month'
|
||||||
|
valueByCategory: Array<{ category: string; quantity: number; value: number }>
|
||||||
|
byWarehouse: Array<{ warehouseId: string; name: string; quantity: number; value: number }>
|
||||||
|
movementTrend: Array<{ period: string; received: number; issued: number; net: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardReports = {
|
||||||
|
sales: (params?: { from?: string; to?: string; regionId?: string; categoryId?: string }) =>
|
||||||
|
api.get<DashboardSalesSummary>('/reports/dashboard/sales', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
inventory: (params?: { from?: string; to?: string; warehouseId?: string; categoryId?: string }) =>
|
||||||
|
api.get<DashboardInventorySummary>('/reports/dashboard/inventory', { params }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user