diff --git a/Distribution/Features/DashboardReport/DashboardReportController.cs b/Distribution/Features/DashboardReport/DashboardReportController.cs new file mode 100644 index 0000000..219bc7c --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportController.cs @@ -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 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 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 }); + } +} diff --git a/Distribution/Features/DashboardReport/DashboardReportDTOs.cs b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs new file mode 100644 index 0000000..5b034f1 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportDTOs.cs @@ -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 Trend, + List ByCategory, List ByRegion, List 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 ValueByCategory, List ByWarehouse, List MovementTrend); diff --git a/Distribution/Features/DashboardReport/DashboardReportRepository.cs b/Distribution/Features/DashboardReport/DashboardReportRepository.cs new file mode 100644 index 0000000..1b2bb32 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportRepository.cs @@ -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 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 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 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> 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> 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> 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> 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> 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 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> 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> 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> 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(); + } +} diff --git a/Distribution/Features/DashboardReport/DashboardReportService.cs b/Distribution/Features/DashboardReport/DashboardReportService.cs new file mode 100644 index 0000000..e393d48 --- /dev/null +++ b/Distribution/Features/DashboardReport/DashboardReportService.cs @@ -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 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 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); + } +} diff --git a/Distribution/Features/DashboardReport/IDashboardReportRepository.cs b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs new file mode 100644 index 0000000..56c81fb --- /dev/null +++ b/Distribution/Features/DashboardReport/IDashboardReportRepository.cs @@ -0,0 +1,16 @@ +namespace Distribution.Features.DashboardReport; + +public record GroupAggregate(Guid? Key, string Name, decimal Amount); + +public interface IDashboardReportRepository +{ + Task GetSalesTotalsAsync(DashboardFilter f); + Task> GetDailySalesAsync(DashboardFilter f); + Task> GetDailyReturnsAsync(DashboardFilter f); + Task> GetSalesByCategoryAsync(DashboardFilter f); + Task> GetSalesByRegionAsync(DashboardFilter f); + Task> GetSalesByRepAsync(DashboardFilter f); + Task> GetStockValueByCategoryAsync(DashboardFilter f); + Task> GetStockByWarehouseAsync(DashboardFilter f); + Task> GetDailyMovementsAsync(DashboardFilter f); +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index e59efe5..ca74100 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -30,6 +30,7 @@ using Distribution.Features.Sync; using Distribution.Features.Uom; using Distribution.Features.User; using Distribution.Features.Vehicle; +using Distribution.Features.DashboardReport; using Distribution.Features.ProductReport; using Distribution.Features.Stock; using Distribution.Features.StockReport; @@ -71,6 +72,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx new file mode 100644 index 0000000..34e55d5 --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx @@ -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 ( +
+
+
+ + {title} +
+ {note &&
{note}
} +
+
{children}
+
+ ) +} + +function Delta({ current, previous }: { current: number; previous: number }) { + if (previous === 0) return No previous-period data + const pct = ((current - previous) / Math.abs(previous)) * 100 + const up = pct >= 0 + return ( + + + {up ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + vs prev. + + ) +} + +function Kpi({ title, value, footer }: { title: string; value: string; footer?: ReactNode }) { + return ( +
+
+
{title}
+
{value}
+
+ {footer &&
{footer}
} +
+ ) +} + +function LoadingGrid() { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => )} +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} + +function useLoad(load: () => Promise, 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

{text}

+} + +/** 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 + const max = Math.max(...rows.map(r => r.value), 1) + return ( +
+ + + {rows.map((r, i) => ( + + + + + + ))} + +
{i + 1} +
{r.name}
+
+
+
+
+ {fmt(r.value)} + {r.extra &&
{r.extra}
} +
+
+ ) +} + +function DataTable({ cols, rows, empty }: { cols: string[]; rows: ReactNode[][]; empty: string }) { + if (rows.length === 0) return + return ( +
+ + + + {cols.map((c, i) => )} + + + + {rows.map((r, ri) => ( + + {r.map((cell, ci) => ( + + ))} + + ))} + +
{c}
{cell}
+
+ ) +} + +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( + () => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics') + const { data: products, loading: productsLoading } = useLoad( + () => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)), + [range.from, range.to], 'Failed to load product sales') + + if (loading || productsLoading || !sales) return + + const { current: c, previous: p } = sales.kpis + const kpi = (title: string, key: keyof SalesKpis, money = true) => ( + } + /> + ) + + 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 ( +
+
+ {kpi('Net Sales', 'netSales')} + {kpi('Gross Sales', 'grossSales')} + {kpi('Total Orders', 'orders', false)} + {kpi('Average Order Value', 'averageOrderValue')} + + {c.grossProfitMarginPercent === null ? 'Margin n/a (no cost prices)' : `${c.grossProfitMarginPercent}% margin`} + + } + /> + {kpi('Sales Returns', 'returnValue')} +
+

+ 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. +

+ +
+ + + + + + + + + [ + r.name, + fmtNum(r.qtyFulfilled), + fmtMoney(r.salesValue), + r.marginPercent === null ? '—' : ( + = 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}>{r.marginPercent}% + ), + ])} + /> + + + ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." /> + + + + + + + ({ name: r.name, value: r.amount, extra: sharePct(r.amount, repTotal) }))} fmt={fmtMoney} empty="No sales in this period." /> + +
+
+ ) +} + +export function InventorySection({ range }: { range: DateRange }) { + const { data: inv, loading } = useLoad( + () => dashboardReports.inventory(range), [range.from, range.to], 'Failed to load inventory analytics') + const { data: overview } = useLoad( + () => 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( + () => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)), + [range.from, range.to], 'Failed to load product sales') + + if (loading || moversLoading || !inv || !overview) return + + 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 ( +
+
+ + + At cost price} /> + + + Batches · {fmtNum(overview.expiredCount)} already expired} /> +
+

+ Reserved stock, excess stock, ageing, discrepancies and pending transfers are not tracked yet, so they are not shown. +

+ +
+ + + + + + + + + [r.name, fmtNum(r.quantity), fmtMoney(r.value)])} + /> + + + ({ name: r.category, value: r.value, extra: sharePct(r.value, catTotal) }))} + fmt={fmtMoney} empty="No stock recorded." + /> + + + + { + const cov = Math.round((r.warehouseQty / (r.reorderLevel ?? 1)) * 100) + return [ + r.name, + fmtNum(r.warehouseQty), + fmtNum(r.reorderLevel ?? 0), + {cov}%, + ] + })} + /> + + +
+
Fast-moving
+ +
Slow-moving
+ +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx index 9b1b3ed..cdd72cf 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx @@ -1,6 +1,6 @@ '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 { 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 - const config = { value: { label: 'Quantity', color } } satisfies ChartConfig + const config = { value: { label, color } } satisfies ChartConfig return ( @@ -140,3 +140,83 @@ export function RadialGauge({ pct, label, sub }: { pct: number; label: string; s ) } + +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>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} + +/** Grouped (side-by-side) column chart comparing several series per category. */ +export function GroupedColumnChart({ data, series, emptyText }: { data: Array>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} + +/** Multi-series smooth area chart with a soft gradient under each line. */ +export function MultiAreaChart({ data, series, emptyText }: { data: Array>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + {series.map(s => ( + + + + + ))} + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx index db253d8..a8e2745 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx @@ -1,14 +1,10 @@ 'use client' 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 type { Layout, LayoutItem } from 'react-grid-layout' +import type { LayoutItem } from 'react-grid-layout' 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 { toast } from '@/components/ui/toast' import { @@ -23,12 +19,12 @@ import { } from '@/lib/api/index' import { useAuth } from '@/contexts/auth-context' 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 type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' -const STORAGE_KEY = 'distribution-dashboard-widgets-v3' -const ROW_HEIGHT = 36 -const GRID_MARGIN: [number, number] = [16, 16] +const ROW_HEIGHT = 26 +const GRID_MARGIN: [number, number] = [10, 10] type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] @@ -83,14 +79,14 @@ const SIZE_CONFIG: Record { - return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record -} - -/** 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 -} - -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 - 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 })}` interface OpenRide { @@ -351,12 +299,51 @@ async function loadDashboardData(activeIds: Set): Promise return data } -function CategoryPill({ cat }: { cat: Category }) { - const meta = CATEGORY_META[cat] +const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus'] + +function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) { return ( - - {meta.label} - +
+
+
{title}
+
{value}
+
+
+ {sub &&
{sub}
} + {children} +
+
+ ) +} + +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 = { + pendingPO: , + awaitingConfirm: , + fulfillRate: ( + +
+ + ), + deliveryTasks: , + outstandingPay: , + invoiceStatus: ( + +
+
+
+
+
+ + ), + } + return ( +
+ {STAT_IDS.filter(id => ids.includes(id)).map(id =>
{tiles[id]}
)} +
) } @@ -365,14 +352,14 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'pendingPO': return (
-
{data.pendingPOCount}
+
{data.pendingPOCount}
{data.totalPOCount} total orders
) case 'awaitingConfirm': return (
-
{data.awaitingConfirmCount}
+
{data.awaitingConfirmCount}
Confirmed, not yet loaded
) @@ -391,7 +378,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
{r.vehicleNumberPlate}
{r.driverFullName}
- Open + Open
))}
@@ -409,7 +396,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'deliveryTasks': return (
-
{data.pendingTasksCount}
+
{data.pendingTasksCount}
{data.partialTasksCount} partially delivered
) @@ -454,7 +441,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'outstandingPay': return (
-
{fmtMoney(data.outstandingAmount)}
+
{fmtMoney(data.outstandingAmount)}
{data.outstandingInvoiceCount} invoices
) @@ -496,30 +483,43 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { } } -function mergeLayoutsChange( - full: Record, - changed: Partial>, -): Record { - 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 +function toIsoDate(d: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +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): 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() { const { hasPermission } = useAuth() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) - const [state, setState] = useState(defaultLayoutState) - const [editMode, setEditMode] = useState(false) - const [showPicker, setShowPicker] = useState(false) - const [category, setCategory] = useState<'all' | Category>('all') + const [range, setRange] = useState(() => presetRange('1m')) + const [preset, setPreset] = useState('1m') const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 }) const allowedIds = useMemo( @@ -528,23 +528,10 @@ export function DashboardClient() { ) const permittedActiveIds = useMemo( - () => state.activeIds.filter(id => allowedIds.has(id)), - [state.activeIds, allowedIds], + () => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)), + [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 () => { setLoading(true) try { @@ -560,118 +547,82 @@ export function DashboardClient() { 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>) { - 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( () => permittedActiveIds .map(id => WIDGET_META.find(w => w.id === id)) .filter((w): w is WidgetMeta => Boolean(w)) - .filter(w => category === 'all' || w.cat === category), - [permittedActiveIds, category], + .filter(w => !STAT_IDS.includes(w.id)), + [permittedActiveIds], ) const visibleLayouts = useMemo(() => { const visibleIds = new Set(visibleWidgets.map(w => w.id)) 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 - }, [state.layouts, visibleWidgets]) + }, [visibleWidgets]) - const filters: Array<{ key: 'all' | Category; label: string }> = [ - { key: 'all', label: 'All widgets' }, - { 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 }, - ] + const canSales = hasPermission('orders.view') + const canInventory = hasPermission('stock.view') return ( -
+
-

Dashboard

+

Dashboard

- {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

+
-
- {editMode && ( - - )} - - - {showPicker && ( -
-
- Widgets - -
-
- {WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => { - const checked = state.activeIds.includes(w.id) - return ( - - ) - })} -
+ ))}
- )} + {preset === 'custom' && ( +
+ e.target.value && setRange(r => ({ ...r, from: e.target.value }))} + className="rounded-lg border bg-background px-2 py-1 text-xs" + /> + to + e.target.value && setRange(r => ({ ...r, to: e.target.value }))} + className="rounded-lg border bg-background px-2 py-1 text-xs" + /> +
+ )} +
-
- -
- {filters.map(f => ( - - ))} -
+ )} + {canSales && ( +
+

Sales

+ +
+ )} + {canInventory && ( +
+

Inventory

+ +
+ )} {loading || !data ? (
@@ -679,11 +630,9 @@ export function DashboardClient() { ))}
- ) : visibleWidgets.length === 0 ? ( -
- No widgets on this dashboard yet. Click “Widgets” to add some. -
) : ( + <> +
{mounted && ( {visibleWidgets.map(w => ( -
- {editMode && ( - - )} -
-
{w.title}
-
+
+
+
+ + {w.title} +
+
@@ -723,6 +665,7 @@ export function DashboardClient() { )}
+ )}
) diff --git a/distribution_fe/app/(dashboard)/users/roles/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/users/roles/[id]/edit/page.tsx new file mode 100644 index 0000000..4150709 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/[id]/edit/page.tsx @@ -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(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 ( +
+
+ +
+

Edit Role

+

Update role details and permissions.

+
+
+ +
+ {loading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ )} + {!loading && role && } +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/users/roles/_components/role-form.tsx b/distribution_fe/app/(dashboard)/users/roles/_components/role-form.tsx new file mode 100644 index 0000000..ab5b38b --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/_components/role-form.tsx @@ -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 + +// '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([]) + const [selectedIds, setSelectedIds] = useState>(new Set()) + + const form = useForm({ + 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 ( +
+ + ( + + )} + /> + ( + + )} + /> + + + {allPermissions.length > 0 && ( +
+

Permissions

+
+
+ {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 ( +
+
+ { + setSelectedIds((prev) => { + const next = new Set(prev) + catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId)) + return next + }) + }} + /> + +
+
+ {catPerms.map((p) => ( +
+ togglePermission(p.permissionId)} + className="mt-0.5" + /> + +
+ ))} +
+
+ ) + })} +
+
+
+ )} + +
+ + +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx b/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx deleted file mode 100644 index ed51e61..0000000 --- a/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx +++ /dev/null @@ -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 - -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([]) - const [selectedIds, setSelectedIds] = useState>(new Set()) - - const form = useForm({ - 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 ( - !v && onClose()}> - - - {isEdit ? 'Edit Role' : 'Add Role'} - - {isEdit ? 'Update role details and permissions.' : 'Create a new role and assign permissions.'} - - - -
- - ( - - )} - /> - ( - - )} - /> - - - {allPermissions.length > 0 && ( -
-

Permissions

-
-
- {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 ( -
-
- { - setSelectedIds((prev) => { - const next = new Set(prev) - catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId)) - return next - }) - }} - /> - -
-
- {catPerms.map((p) => ( -
- togglePermission(p.permissionId)} - className="mt-0.5" - /> - -
- ))} -
-
- ) - })} -
-
-
- )} - - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx b/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx index 108cd7d..f757c5e 100644 --- a/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx +++ b/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' 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 type { RoleResponse } from '@/types/api' -import { RoleSheet } from './role-sheet' import { RoleViewSheet } from './role-view-sheet' export function RolesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [pageSize, setPageSize] = useState(20) - const [sheetOpen, setSheetOpen] = useState(false) - const [editing, setEditing] = useState(null) const [viewOpen, setViewOpen] = useState(false) const [viewing, setViewing] = useState(null) @@ -41,8 +40,8 @@ export function RolesClient() { useEffect(() => { fetchData() }, [fetchData]) - function openCreate() { setEditing(null); setSheetOpen(true) } - function openEdit(role: RoleResponse) { setEditing(role); setSheetOpen(true) } + function openCreate() { router.push('/users/roles/new') } + function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) } function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) } async function handleDeactivate(role: RoleResponse) { @@ -122,13 +121,6 @@ export function RolesClient() { onServerSideChange={({ page: p }) => setPageSize(p.pageSize)} /> - setSheetOpen(false)} - role={editing} - onSuccess={() => fetchData()} - /> - setViewOpen(false)} diff --git a/distribution_fe/app/(dashboard)/users/roles/new/page.tsx b/distribution_fe/app/(dashboard)/users/roles/new/page.tsx new file mode 100644 index 0000000..87e0fb6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/new/page.tsx @@ -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 ( +
+
+ +
+

Add Role

+

Create a new role and assign permissions.

+
+
+ +
+ +
+
+ ) +} diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 08ff4ca..e38e8ed 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers' export { purchaseOrders, grn, purchaseReturns } from './procurement' export { rides, loadingSheets, deliveryTasks } from './operations' export { stock } from './stock' -export { productReports } from './reports' +export { productReports, stockReports, dashboardReports } from './reports' export { shopPos, invoices, payments } from './sales' export { saleReturns, claims, supplierClaims } from './returns-claims' export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config' diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts index 8d5122e..ce91694 100644 --- a/distribution_fe/lib/api/reports.ts +++ b/distribution_fe/lib/api/reports.ts @@ -159,3 +159,41 @@ export const productReports = { summary: (params?: Pick) => api.get('/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('/reports/dashboard/sales', { params }).then(r => r.data), + + inventory: (params?: { from?: string; to?: string; warehouseId?: string; categoryId?: string }) => + api.get('/reports/dashboard/inventory', { params }).then(r => r.data), +}