Merge pull request 'Fix dashboard stat card handling issue' (#84) from feat-notification into dev
Reviewed-on: #84
This commit was merged in pull request #84.
This commit is contained in:
@@ -2,21 +2,27 @@ 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 SalesTotals(decimal GrossSales, decimal ReturnValue, decimal Discounts, int Orders, decimal CostedSales, decimal CostOfSales);
|
||||
|
||||
public record SalesKpis(
|
||||
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
decimal GrossSales, decimal ReturnValue, decimal Discounts, decimal NetSales, int Orders, decimal AverageOrderValue,
|
||||
decimal GrossProfit, decimal? GrossProfitMarginPercent);
|
||||
|
||||
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
|
||||
|
||||
public record ReceivablesTotals(decimal Collected, decimal Outstanding, decimal Overdue);
|
||||
|
||||
public record CashKpis(decimal Collected, decimal Outstanding, decimal Overdue, decimal? CollectionRatePercent);
|
||||
|
||||
public record CashKpiComparison(CashKpis Current, CashKpis Previous);
|
||||
|
||||
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,
|
||||
SalesKpiComparison Kpis, CashKpiComparison Cash, List<TrendPoint> Trend,
|
||||
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
|
||||
|
||||
public record PeriodAmount(DateTime Date, decimal Amount);
|
||||
|
||||
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
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).
|
||||
// Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
|
||||
// Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
|
||||
// Payment status/method is deliberately ignored: a sale is recognised when invoiced.
|
||||
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
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& li.Invoice.FinalizedAt >= start && li.Invoice.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,
|
||||
Date = li.Invoice.FinalizedAt!.Value.Date,
|
||||
PoId = li.Invoice.FK_ShopPOId,
|
||||
Value = li.Quantity * li.UnitPrice,
|
||||
Cost = li.Item.CostPrice == null ? null : li.Quantity * 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,
|
||||
RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
|
||||
RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
|
||||
RepId = li.Invoice.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);
|
||||
@@ -52,8 +53,10 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
// Only credit-deduction returns reduce sales; an item exchange is replaced like-for-like (net zero).
|
||||
var q = db.SaleReturnItems.AsNoTracking()
|
||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
|
||||
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end
|
||||
&& r.SaleReturn.Mode == ReturnMode.CreditDeduction);
|
||||
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;
|
||||
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
|
||||
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);
|
||||
return new SalesTotals(gross, returns, await GetDiscountsAsync(f), orders, costedSales, cost);
|
||||
}
|
||||
|
||||
// Discount carried on the invoice (Invoice.DiscountAmount) for invoices finalized in the range, same dating as sales.
|
||||
// A discount belongs to the whole invoice, so it can't be split by category: with a category filter it is 0.
|
||||
private async Task<decimal> GetDiscountsAsync(DashboardFilter f)
|
||||
{
|
||||
if (f.CategoryId.HasValue) return 0m;
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
var q = db.Invoices.AsNoTracking()
|
||||
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized && i.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& i.FinalizedAt >= start && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue) q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
return await q.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
}
|
||||
|
||||
// Cash view, independent of Net Sales. Credit-method payments are store-credit usage, so they stay
|
||||
// receivable (not collected). Outstanding/Overdue are balances as of the end of the range;
|
||||
// overdue = unpaid balance on invoices finalized more than OverdueDays before that date.
|
||||
private const int OverdueDays = 30;
|
||||
|
||||
public async Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f)
|
||||
{
|
||||
var start = f.From.Date;
|
||||
var end = f.To.Date.AddDays(1);
|
||||
|
||||
var pays = db.Payments.AsNoTracking().Where(p => p.IsActive && p.Method != PaymentMethod.Credit);
|
||||
var invs = db.Invoices.AsNoTracking().Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
|
||||
&& i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end);
|
||||
if (f.RegionId.HasValue)
|
||||
{
|
||||
pays = pays.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
invs = invs.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
}
|
||||
|
||||
var collected = await pays.Where(p => p.CollectedAt >= start && p.CollectedAt < end)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
|
||||
var rows = await invs.Select(i => new
|
||||
{
|
||||
i.FinalizedAt,
|
||||
i.TotalAmount,
|
||||
Paid = i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end)
|
||||
.Sum(p => (decimal?)p.Amount) ?? 0m,
|
||||
}).ToListAsync();
|
||||
|
||||
var balances = rows
|
||||
.Select(r => (Due: r.FinalizedAt!.Value.AddDays(OverdueDays), Balance: Math.Max(0m, r.TotalAmount - r.Paid)))
|
||||
.ToList();
|
||||
return new ReceivablesTotals(collected,
|
||||
balances.Sum(b => b.Balance),
|
||||
balances.Where(b => b.Due < end).Sum(b => b.Balance));
|
||||
}
|
||||
|
||||
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
|
||||
public class DashboardReportService(IDashboardReportRepository repo)
|
||||
{
|
||||
public const int MaxRangeDays = 366;
|
||||
private const int DailyGranularityMaxDays = 62;
|
||||
private const int DailyGranularityMaxDays = 92; // covers a "last 3 months" daily trend
|
||||
|
||||
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
|
||||
{
|
||||
@@ -25,14 +25,18 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
private static SalesKpis ToKpis(SalesTotals t)
|
||||
{
|
||||
var net = t.GrossSales - t.ReturnValue;
|
||||
var net = t.GrossSales - t.ReturnValue - t.Discounts;
|
||||
var gp = t.CostedSales - t.CostOfSales;
|
||||
return new SalesKpis(
|
||||
t.GrossSales, t.ReturnValue, net, t.Orders,
|
||||
t.GrossSales, t.ReturnValue, t.Discounts, 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);
|
||||
}
|
||||
|
||||
private static CashKpis ToCash(ReceivablesTotals r, decimal grossInvoiced) =>
|
||||
new(r.Collected, r.Outstanding, r.Overdue,
|
||||
grossInvoiced > 0 ? Math.Round(r.Collected / grossInvoiced * 100, 2) : null);
|
||||
|
||||
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
|
||||
{
|
||||
var days = (f.To - f.From).Days + 1;
|
||||
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
|
||||
var current = await repo.GetSalesTotalsAsync(f);
|
||||
var previous = await repo.GetSalesTotalsAsync(prev);
|
||||
var currentCash = await repo.GetReceivablesAsync(f);
|
||||
var previousCash = await repo.GetReceivablesAsync(prev);
|
||||
var sales = await repo.GetDailySalesAsync(f);
|
||||
var returns = await repo.GetDailyReturnsAsync(f);
|
||||
|
||||
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
|
||||
return new SalesSummaryResponse(
|
||||
f.From, f.To, daily ? "day" : "month",
|
||||
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
|
||||
new CashKpiComparison(
|
||||
ToCash(currentCash, current.GrossSales - current.Discounts),
|
||||
ToCash(previousCash, previous.GrossSales - previous.Discounts)),
|
||||
trend,
|
||||
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
|
||||
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
|
||||
|
||||
@@ -5,6 +5,7 @@ public record GroupAggregate(Guid? Key, string Name, decimal Amount);
|
||||
public interface IDashboardReportRepository
|
||||
{
|
||||
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
|
||||
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
|
||||
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
|
||||
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
|
||||
|
||||
@@ -9,6 +9,7 @@ public interface IInvoiceRepository
|
||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
Task<Models.Item?> GetItemAsync(Guid itemId);
|
||||
Task AddItemsAsync(IEnumerable<InvoiceItem> items);
|
||||
|
||||
@@ -45,6 +45,7 @@ public record InvoiceResponse(
|
||||
DateTime? FinalizedAt,
|
||||
Guid? FinalizedBy,
|
||||
decimal TotalAmount,
|
||||
decimal DiscountAmount,
|
||||
decimal PaidAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
|
||||
@@ -51,6 +51,11 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<decimal> GetSchemeDiscountAsync(Guid shopPOId) =>
|
||||
await context.SchemeRedemptions
|
||||
.Where(r => r.FK_ShopPOId == shopPOId)
|
||||
.SumAsync(r => r.DiscountAmount) ?? 0m;
|
||||
|
||||
public async Task AddAsync(Models.Invoice invoice) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public class InvoiceService(
|
||||
i.Status, i.PaymentStatus, i.CreatedBy,
|
||||
i.PrintedAt, i.PrintedBy,
|
||||
i.FinalizedAt, i.FinalizedBy,
|
||||
i.TotalAmount,
|
||||
i.TotalAmount, i.DiscountAmount,
|
||||
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
|
||||
i.IsActive, i.CreatedAt,
|
||||
i.Items.Where(x => x.IsActive).Select(x =>
|
||||
@@ -30,6 +30,15 @@ public class InvoiceService(
|
||||
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
|
||||
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice);
|
||||
|
||||
// TotalAmount = billable lines - scheme discount redeemed on the PO. Re-derived whenever lines change
|
||||
// and at finalize, so a scheme applied after the invoice was drafted is still picked up before it locks.
|
||||
private async Task ApplyTotalsAsync(Models.Invoice invoice, IEnumerable<InvoiceItem> items)
|
||||
{
|
||||
var billable = BillableTotal(items);
|
||||
invoice.DiscountAmount = Math.Min(await repo.GetSchemeDiscountAsync(invoice.FK_ShopPOId), billable);
|
||||
invoice.TotalAmount = billable - invoice.DiscountAmount;
|
||||
}
|
||||
|
||||
// Immutable copy of the invoice lines as they stand right now.
|
||||
public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
|
||||
new()
|
||||
@@ -115,15 +124,15 @@ public class InvoiceService(
|
||||
FK_ShopPOId = po.Id,
|
||||
Status = InvoiceStatus.Draft,
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
CreatedBy = createdBy,
|
||||
TotalAmount = po.TotalAmount
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||
await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(invoice);
|
||||
var lines = BuildItemsFromShopPO(po, invoice.Id);
|
||||
await repo.AddItemsAsync(lines);
|
||||
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
|
||||
po.InvoiceId = invoice.Id;
|
||||
@@ -192,7 +201,7 @@ public class InvoiceService(
|
||||
throw new InvalidOperationException("Each edit needs an invoiceItemId (existing line) or an itemId (new line)");
|
||||
}
|
||||
|
||||
invoice.TotalAmount = BillableTotal(invoice.Items.Concat(newLines));
|
||||
await ApplyTotalsAsync(invoice, invoice.Items.Concat(newLines));
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
@@ -223,6 +232,10 @@ public class InvoiceService(
|
||||
if (invoice.Status != InvoiceStatus.Draft)
|
||||
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status");
|
||||
|
||||
// Printing fixes the amount the customer sees, so pick up any scheme discount applied since the last edit.
|
||||
var lines = invoice.Items.Where(i => i.IsActive).ToList();
|
||||
if (lines.Count > 0) await ApplyTotalsAsync(invoice, lines);
|
||||
|
||||
invoice.Status = InvoiceStatus.Printed;
|
||||
invoice.PrintedAt = DateTime.Now;
|
||||
invoice.PrintedBy = printedBy;
|
||||
@@ -265,7 +278,7 @@ public class InvoiceService(
|
||||
}
|
||||
|
||||
if (items.Count > 0)
|
||||
invoice.TotalAmount = BillableTotal(items);
|
||||
await ApplyTotalsAsync(invoice, items);
|
||||
|
||||
invoice.Status = InvoiceStatus.Finalized;
|
||||
invoice.FinalizedAt = DateTime.Now;
|
||||
|
||||
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f)
|
||||
// Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
|
||||
private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
|
||||
{
|
||||
var lines =
|
||||
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
|
||||
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
from li in db.InvoiceItems.AsNoTracking()
|
||||
where li.IsActive && !li.IsRemoved
|
||||
&& li.Invoice.Status == InvoiceStatus.Finalized
|
||||
&& li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
|
||||
&& (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
|
||||
&& (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
|
||||
select li;
|
||||
|
||||
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value);
|
||||
if (f.CategoryId.HasValue) lines = lines.Where(l => l.Item.FK_CategoryId == f.CategoryId.Value);
|
||||
if (f.SubCategoryId.HasValue) lines = lines.Where(l => l.Item.FK_SubCategoryId == f.SubCategoryId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (f.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
|
||||
if (f.RouteId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.FK_RouteId == f.RouteId.Value);
|
||||
if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(f.Search))
|
||||
{
|
||||
var s = f.Search.Trim();
|
||||
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice,
|
||||
Orders = g.Select(x => x.FK_ShopPOId).Distinct().Count(),
|
||||
QtyOrdered = g.Sum(x => (decimal)x.Quantity),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled),
|
||||
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
|
||||
QtyOrdered = g.Sum(x => (decimal)(x.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
|
||||
QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
|
||||
SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
|
||||
});
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
|
||||
// Distinct stores per item is counted separately: MySQL can't run the correlated
|
||||
// Distinct orders and stores per item are counted separately: MySQL can't run the correlated
|
||||
// "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
|
||||
var ids = page_.Select(r => r.FK_ItemId).ToList();
|
||||
var storePairs = await SoldLines(f)
|
||||
var pairs = await SoldLines(f)
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
|
||||
.Select(l => new { l.FK_ItemId, PoId = l.Invoice.FK_ShopPOId, l.Invoice.ShopPO.FK_StoreId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.Count());
|
||||
var orderCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.PoId).Distinct().Count());
|
||||
var storeCounts = pairs.GroupBy(p => p.FK_ItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(p => p.FK_StoreId).Distinct().Count());
|
||||
|
||||
var rows = page_.Select(r => new SalesAggregate(r.FK_ItemId, r.Sku, r.Name, r.CostPrice,
|
||||
r.Orders, storeCounts.GetValueOrDefault(r.FK_ItemId), r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
orderCounts.GetValueOrDefault(r.FK_ItemId), storeCounts.GetValueOrDefault(r.FK_ItemId),
|
||||
r.QtyOrdered, r.QtyFulfilled, r.SalesValue)).ToList();
|
||||
return (rows, total);
|
||||
}
|
||||
|
||||
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
|
||||
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
|
||||
SoldLines(f).SumAsync(x => x.Quantity * x.UnitPrice);
|
||||
|
||||
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
|
||||
{
|
||||
@@ -127,7 +130,7 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
|
||||
return await SoldLines(f with { Search = null })
|
||||
.Where(l => ids.Contains(l.FK_ItemId))
|
||||
.GroupBy(l => l.FK_ItemId)
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.QuantityFulfilled) })
|
||||
.Select(g => new { g.Key, Qty = g.Sum(x => (decimal)x.Quantity) })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Qty);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface ISchemeRepository
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
|
||||
|
||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||
|
||||
@@ -52,6 +52,10 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == shopPOId);
|
||||
|
||||
// An invoice past Draft (printed or finalized) has a fixed amount, so it can no longer take a discount.
|
||||
public async Task<bool> HasLockedInvoiceAsync(Guid shopPOId) =>
|
||||
await context.Invoices.AnyAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive && i.Status != Models.InvoiceStatus.Draft);
|
||||
|
||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||
await context.SchemeBundleComponents
|
||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||
|
||||
@@ -383,6 +383,9 @@ public class SchemeService(
|
||||
if (await repo.HasRedemptionAsync(schemeId, shopPOId))
|
||||
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO.");
|
||||
|
||||
if (await repo.HasLockedInvoiceAsync(shopPOId))
|
||||
throw new InvalidOperationException("This order's invoice is already printed or finalized, so a scheme can no longer be applied. Apply schemes before the invoice is printed.");
|
||||
|
||||
var items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
|
||||
var eligibility = EvaluateRule(scheme, items);
|
||||
if (!eligibility.Eligible)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Distribution.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceDiscount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DiscountAmount",
|
||||
table: "Invoices",
|
||||
type: "decimal(65,30)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Invoices",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34500000-0000-0000-0000-000000000001"),
|
||||
column: "DiscountAmount",
|
||||
value: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Invoices",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("34500000-0000-0000-0000-000000000002"),
|
||||
column: "DiscountAmount",
|
||||
value: 0m);
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DiscountAmount",
|
||||
table: "Invoices");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,9 @@ namespace Distribution.Migrations
|
||||
b.Property<Guid>("CreatedBy")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal>("DiscountAmount")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<Guid>("FK_ShopPOId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -887,6 +890,7 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("34500000-0000-0000-0000-000000000001"),
|
||||
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified),
|
||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"),
|
||||
DiscountAmount = 0m,
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
|
||||
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"),
|
||||
@@ -902,6 +906,7 @@ namespace Distribution.Migrations
|
||||
Id = new Guid("34500000-0000-0000-0000-000000000002"),
|
||||
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
|
||||
DiscountAmount = 0m,
|
||||
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
|
||||
IsActive = true,
|
||||
PaymentStatus = 1,
|
||||
|
||||
@@ -19,6 +19,10 @@ namespace Distribution.Models
|
||||
public DateTime? FinalizedAt { get; set; }
|
||||
public Guid? FinalizedBy { get; set; }
|
||||
|
||||
/// <summary>Scheme discount redeemed against the PO, subtracted from the billable lines to give TotalAmount.</summary>
|
||||
public decimal DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>Payable amount: billable lines minus DiscountAmount.</summary>
|
||||
public decimal TotalAmount { get; set; }
|
||||
|
||||
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>();
|
||||
|
||||
@@ -7,6 +7,8 @@ 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'
|
||||
import { RegionSalesPie } from './region-sales-pie'
|
||||
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||
|
||||
export interface DateRange {
|
||||
from: string // yyyy-MM-dd
|
||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
||||
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 className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{Array.from({ length: 7 }).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) => (
|
||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||
|
||||
const { current: c, previous: p } = sales.kpis
|
||||
const { current: cash, previous: cashPrev } = sales.cash
|
||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||
<Kpi
|
||||
title={title}
|
||||
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
)
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{kpi('Net Sales', 'netSales')}
|
||||
{kpi('Gross Sales', 'grossSales')}
|
||||
{kpi('Total Orders', 'orders', false)}
|
||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
}
|
||||
/>
|
||||
{kpi('Sales Returns', 'returnValue')}
|
||||
{kpi('Discounts', 'discounts')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Kpi
|
||||
title="Collected"
|
||||
value={fmtMoney(cash.collected)}
|
||||
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
|
||||
/>
|
||||
<Kpi
|
||||
title="Outstanding"
|
||||
value={fmtMoney(cash.outstanding)}
|
||||
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Overdue"
|
||||
value={fmtMoney(cash.overdue)}
|
||||
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
|
||||
/>
|
||||
<Kpi
|
||||
title="Collection Rate"
|
||||
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
|
||||
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
|
||||
/>
|
||||
</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.
|
||||
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||
Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
|
||||
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
|
||||
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
|
||||
Gross profit uses item cost prices and covers only items that have one. 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>
|
||||
<SalesTrendInteractive className="md:col-span-2" />
|
||||
<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>
|
||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
])}
|
||||
/>
|
||||
</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>
|
||||
<RegionSalesPie rows={sales.byRegion} />
|
||||
|
||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
||||
import type { 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'
|
||||
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
|
||||
const DEFAULT_ACTIVE = [
|
||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
||||
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||
]
|
||||
|
||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
||||
confirmedSheetsCount: number
|
||||
pendingTasksCount: number
|
||||
partialTasksCount: number
|
||||
lowStock: LowStockItemResponse[]
|
||||
movements: StockMovementResponse[]
|
||||
warehouseBalances: WarehouseBalanceRow[]
|
||||
outstandingAmount: number
|
||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
||||
openRides: [],
|
||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||
pendingTasksCount: 0, partialTasksCount: 0,
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('lowStock')) {
|
||||
fetches.push(
|
||||
stock.lowStock()
|
||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
||||
.catch(() => {})
|
||||
)
|
||||
}
|
||||
|
||||
if (activeIds.has('stockMovements')) {
|
||||
fetches.push(
|
||||
stock.movements({ pageSize: 5 })
|
||||
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||
</div>
|
||||
)
|
||||
case 'lowStock':
|
||||
return data.lowStock.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
||||
</div>
|
||||
{data.lowStock.map(row => (
|
||||
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
||||
<span className="text-foreground">{row.itemName}</span>
|
||||
<span className="text-muted-foreground">{row.locationName}</span>
|
||||
<span className="font-semibold text-destructive">{row.quantity}</span>
|
||||
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'stockMovements':
|
||||
return data.movements.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Label, Pie, PieChart, Sector } from 'recharts'
|
||||
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||
const MAX_SLICES = 5
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
|
||||
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
|
||||
function topWithOther(rows: Array<{ name: string; amount: number }>) {
|
||||
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
|
||||
if (sorted.length <= MAX_SLICES) return sorted
|
||||
const head = sorted.slice(0, MAX_SLICES - 1)
|
||||
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
|
||||
* out and show its amount and share in the centre.
|
||||
*/
|
||||
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
|
||||
const slices = useMemo(
|
||||
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
|
||||
[rows],
|
||||
)
|
||||
const config = useMemo(
|
||||
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
|
||||
[slices],
|
||||
)
|
||||
const total = slices.reduce((t, s) => t + s.amount, 0)
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
|
||||
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
|
||||
const activeIndex = slices.findIndex(s => s.key === activeKey)
|
||||
const active = slices[activeIndex]
|
||||
|
||||
const renderShape = useCallback(
|
||||
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
|
||||
index === activeIndex ? (
|
||||
<g>
|
||||
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
|
||||
</g>
|
||||
) : (
|
||||
<Sector {...props} outerRadius={outerRadius} />
|
||||
),
|
||||
[activeIndex],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales by Region</CardTitle>
|
||||
<CardDescription>Share of gross sales</CardDescription>
|
||||
{active && (
|
||||
<CardAction>
|
||||
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
|
||||
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{slices.map((s, i) => (
|
||||
<SelectItem key={s.key} value={s.key} label={s.name}>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
|
||||
{s.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 justify-center">
|
||||
{!active ? (
|
||||
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
|
||||
) : (
|
||||
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
|
||||
</div>
|
||||
)} />}
|
||||
/>
|
||||
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const cy = viewBox.cy ?? 0
|
||||
return (
|
||||
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
|
||||
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
|
||||
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, getApiError } from '@/lib/api/index'
|
||||
import type { DashboardSalesSummary } from '@/lib/api/reports'
|
||||
|
||||
const MAX_DAYS = 90
|
||||
|
||||
const RANGES = [
|
||||
{ value: '90', label: 'Last 3 months' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
||||
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
|
||||
|
||||
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
interface Loaded {
|
||||
end: string // yyyy-MM-dd of the last day covered
|
||||
trend: DashboardSalesSummary['trend']
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
|
||||
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
|
||||
*/
|
||||
export function SalesTrendInteractive({ className }: { className?: string }) {
|
||||
const [range, setRange] = useState('90')
|
||||
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const today = new Date()
|
||||
dashboardReports
|
||||
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
|
||||
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
|
||||
setLoaded(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const days = Number(range)
|
||||
const data = useMemo(() => {
|
||||
if (!loaded) return []
|
||||
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
|
||||
const end = parseIso(loaded.end)
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const date = toIso(addDays(end, i - (days - 1)))
|
||||
const point = byDay.get(date)
|
||||
return { date, sales: point?.sales ?? 0 }
|
||||
})
|
||||
}, [loaded, days])
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.sales, 0)
|
||||
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Trend</CardTitle>
|
||||
<CardDescription>
|
||||
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Select value={range} onValueChange={v => v && setRange(v)}>
|
||||
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loaded === undefined ? (
|
||||
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||
) : loaded === null || data.every(d => d.sales === 0) ? (
|
||||
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
|
||||
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
|
||||
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
|
||||
/>
|
||||
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -179,6 +179,7 @@ export default function InvoiceViewPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
||||
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
|
||||
{invoice.discountAmount > 0 && <Stat label="Scheme discount (included)" value={fmtAmount(invoice.discountAmount)} />}
|
||||
<Stat label="Paid" value={fmtAmount(paidAmount)} />
|
||||
<Stat label="Balance" value={fmtAmount(balance)} />
|
||||
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
|
||||
|
||||
@@ -163,6 +163,7 @@ export const productReports = {
|
||||
export interface SalesKpis {
|
||||
grossSales: number
|
||||
returnValue: number
|
||||
discounts: number
|
||||
netSales: number
|
||||
orders: number
|
||||
averageOrderValue: number
|
||||
@@ -170,11 +171,19 @@ export interface SalesKpis {
|
||||
grossProfitMarginPercent: number | null
|
||||
}
|
||||
|
||||
export interface CashKpis {
|
||||
collected: number
|
||||
outstanding: number
|
||||
overdue: number
|
||||
collectionRatePercent: number | null
|
||||
}
|
||||
|
||||
export interface DashboardSalesSummary {
|
||||
from: string
|
||||
to: string
|
||||
granularity: 'day' | 'month'
|
||||
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
|
||||
cash: { current: CashKpis; previous: CashKpis }
|
||||
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 }>
|
||||
|
||||
@@ -1045,6 +1045,7 @@ export interface InvoiceResponse {
|
||||
finalizedAt: string | null
|
||||
finalizedBy: string | null
|
||||
totalAmount: number
|
||||
discountAmount: number
|
||||
paidAmount: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
|
||||
Reference in New Issue
Block a user