Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m57s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m40s

This commit is contained in:
Kalana B. Thilakarathna
2026-10-19 21:36:08 +05:30
16 changed files with 1259 additions and 444 deletions
@@ -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);
}
+3
View File
@@ -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) {
const changedArr = changed[bp]
if (!changedArr) continue
next[bp] = full[bp].map(item => {
const updated = changedArr.find(c => c.i === item.i)
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
})
} }
return next
type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom'
const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [
{ key: '7d', label: 'Last 7 days' },
{ key: '1m', label: '1 month' },
{ key: '3m', label: '3 months' },
{ key: '1y', label: '1 year' },
{ key: 'custom', label: 'Custom' },
]
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 &ldquo;Widgets&rdquo; 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>
)
}
+1 -1
View File
@@ -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'
+38
View File
@@ -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),
}