Merge pull request 'Fix dashoard issues' (#85) from dev into production

Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
2026-10-22 10:55:41 +05:30
23 changed files with 6598 additions and 93 deletions
@@ -2,21 +2,27 @@ namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId); 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( 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); decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo); 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 TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount); public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse( public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity, 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); List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount); public record PeriodAmount(DateTime Date, decimal Amount);
@@ -19,29 +19,30 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
public Guid RepId { get; set; } public Guid RepId { get; set; }
} }
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs // Same definition of "sold" as ProductReportRepository: billable (non-removed) invoice lines of
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day). // 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) private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{ {
var start = f.From.Date; var start = f.From.Date;
var end = f.To.Date.AddDays(1); var end = f.To.Date.AddDays(1);
var q = var q =
from li in db.ShopPOItems.AsNoTracking() from li in db.InvoiceItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId where li.IsActive && !li.IsRemoved
where inv.Status == InvoiceStatus.Finalized && li.Invoice.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled && li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& inv.FinalizedAt >= start && inv.FinalizedAt < end && li.Invoice.FinalizedAt >= start && li.Invoice.FinalizedAt < end
select new SoldLine select new SoldLine
{ {
Date = inv.FinalizedAt!.Value.Date, Date = li.Invoice.FinalizedAt!.Value.Date,
PoId = li.FK_ShopPOId, PoId = li.Invoice.FK_ShopPOId,
Value = li.QuantityFulfilled * li.UnitPrice, Value = li.Quantity * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice, Cost = li.Item.CostPrice == null ? null : li.Quantity * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId, CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name, CategoryName = li.Item.Category.Name,
RegionId = li.ShopPO.Store.Route.FK_RegionId, RegionId = li.Invoice.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name, RegionName = li.Invoice.ShopPO.Store.Route.Region.Name,
RepId = li.ShopPO.RaisedById, RepId = li.Invoice.ShopPO.RaisedById,
}; };
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value); 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); 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 start = f.From.Date;
var end = f.To.Date.AddDays(1); 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() 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.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); if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q; return q;
@@ -68,7 +71,59 @@ public class DashboardReportRepository(AppDBContext db) : IDashboardReportReposi
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m; var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m; var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 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) => public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
@@ -3,7 +3,7 @@ namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo) public class DashboardReportService(IDashboardReportRepository repo)
{ {
public const int MaxRangeDays = 366; 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) 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) 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; var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis( 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, t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null); 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) public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{ {
var days = (f.To - f.From).Days + 1; var days = (f.To - f.From).Days + 1;
@@ -41,6 +45,8 @@ public class DashboardReportService(IDashboardReportRepository repo)
var current = await repo.GetSalesTotalsAsync(f); var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev); 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 sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f); var returns = await repo.GetDailyReturnsAsync(f);
@@ -55,6 +61,9 @@ public class DashboardReportService(IDashboardReportRepository repo)
return new SalesSummaryResponse( return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month", f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To), 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, trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(), (await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(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 public interface IDashboardReportRepository
{ {
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f); Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<ReceivablesTotals> GetReceivablesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f); Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f); Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(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<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<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync(); Task<List<Models.Invoice>> GetOutstandingAsync();
Task<decimal> GetSchemeDiscountAsync(Guid shopPOId);
Task AddAsync(Models.Invoice invoice); Task AddAsync(Models.Invoice invoice);
Task<Models.Item?> GetItemAsync(Guid itemId); Task<Models.Item?> GetItemAsync(Guid itemId);
Task AddItemsAsync(IEnumerable<InvoiceItem> items); Task AddItemsAsync(IEnumerable<InvoiceItem> items);
@@ -45,6 +45,7 @@ public record InvoiceResponse(
DateTime? FinalizedAt, DateTime? FinalizedAt,
Guid? FinalizedBy, Guid? FinalizedBy,
decimal TotalAmount, decimal TotalAmount,
decimal DiscountAmount,
decimal PaidAmount, decimal PaidAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt,
@@ -51,6 +51,11 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid) .Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
.ToListAsync(); .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) => public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice); await context.Invoices.AddAsync(invoice);
@@ -19,7 +19,7 @@ public class InvoiceService(
i.Status, i.PaymentStatus, i.CreatedBy, i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy, i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy, i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.TotalAmount, i.DiscountAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m, i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt, i.IsActive, i.CreatedAt,
i.Items.Where(x => x.IsActive).Select(x => i.Items.Where(x => x.IsActive).Select(x =>
@@ -30,6 +30,15 @@ public class InvoiceService(
private static decimal BillableTotal(IEnumerable<InvoiceItem> items) => private static decimal BillableTotal(IEnumerable<InvoiceItem> items) =>
items.Where(i => i.IsActive && !i.IsRemoved).Sum(i => i.Quantity * i.UnitPrice); 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. // 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) => public static InvoiceVersion BuildVersion(Models.Invoice invoice, IEnumerable<InvoiceItem> items, int versionNumber, InvoiceVersionStage stage, Guid userId) =>
new() new()
@@ -115,15 +124,15 @@ public class InvoiceService(
FK_ShopPOId = po.Id, FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft, Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending, PaymentStatus = PaymentStatus.Pending,
CreatedBy = createdBy, CreatedBy = createdBy
TotalAmount = po.TotalAmount
}; };
var lines = BuildItemsFromShopPO(po, invoice.Id);
await ApplyTotalsAsync(invoice, lines);
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
try try
{ {
await repo.AddAsync(invoice); await repo.AddAsync(invoice);
var lines = BuildItemsFromShopPO(po, invoice.Id);
await repo.AddItemsAsync(lines); await repo.AddItemsAsync(lines);
await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy)); await repo.AddVersionAsync(BuildVersion(invoice, lines, 1, InvoiceVersionStage.Created, createdBy));
po.InvoiceId = invoice.Id; 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)"); 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); await uow.BeginAsync(ct);
try try
@@ -223,6 +232,10 @@ public class InvoiceService(
if (invoice.Status != InvoiceStatus.Draft) if (invoice.Status != InvoiceStatus.Draft)
throw new InvalidOperationException($"Cannot print an invoice in {invoice.Status} status"); 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.Status = InvoiceStatus.Printed;
invoice.PrintedAt = DateTime.Now; invoice.PrintedAt = DateTime.Now;
invoice.PrintedBy = printedBy; invoice.PrintedBy = printedBy;
@@ -265,7 +278,7 @@ public class InvoiceService(
} }
if (items.Count > 0) if (items.Count > 0)
invoice.TotalAmount = BillableTotal(items); await ApplyTotalsAsync(invoice, items);
invoice.Status = InvoiceStatus.Finalized; invoice.Status = InvoiceStatus.Finalized;
invoice.FinalizedAt = DateTime.Now; invoice.FinalizedAt = DateTime.Now;
@@ -60,24 +60,24 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
return (rows, total); return (rows, total);
} }
// Sold = shop PO lines on non-cancelled POs whose invoice is Finalized, dated by Invoice.FinalizedAt. // Sold = billable (non-removed) invoice lines of Finalized invoices on non-cancelled POs, dated by Invoice.FinalizedAt.
private IQueryable<ShopPOItem> SoldLines(ProductReportFilter f) private IQueryable<InvoiceItem> SoldLines(ProductReportFilter f)
{ {
var lines = var lines =
from li in db.ShopPOItems.AsNoTracking() from li in db.InvoiceItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId where li.IsActive && !li.IsRemoved
where inv.Status == InvoiceStatus.Finalized && li.Invoice.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled && li.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& (!f.From.HasValue || inv.FinalizedAt >= f.From.Value) && (!f.From.HasValue || li.Invoice.FinalizedAt >= f.From.Value)
&& (!f.To.HasValue || inv.FinalizedAt < f.To.Value.Date.AddDays(1)) && (!f.To.HasValue || li.Invoice.FinalizedAt < f.To.Value.Date.AddDays(1))
select li; select li;
if (f.BrandId.HasValue) lines = lines.Where(l => l.Item.FK_BrandId == f.BrandId.Value); 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.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.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.StoreId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) lines = lines.Where(l => l.ShopPO.Store.FK_RouteId == f.RouteId.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.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value); if (f.RegionId.HasValue) lines = lines.Where(l => l.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search)) if (!string.IsNullOrWhiteSpace(f.Search))
{ {
var s = f.Search.Trim(); var s = f.Search.Trim();
@@ -93,33 +93,36 @@ public class ProductReportRepository(AppDBContext db) : IProductReportRepository
.Select(g => new .Select(g => new
{ {
g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.CostPrice, 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.ShopPOItem != null ? x.ShopPOItem.Quantity : x.Quantity)),
QtyOrdered = g.Sum(x => (decimal)x.Quantity), QtyFulfilled = g.Sum(x => (decimal)x.Quantity),
QtyFulfilled = g.Sum(x => (decimal)x.QuantityFulfilled), SalesValue = g.Sum(x => x.Quantity * x.UnitPrice),
SalesValue = g.Sum(x => x.QuantityFulfilled * x.UnitPrice),
}); });
var total = await query.CountAsync(); var total = await query.CountAsync();
var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name) var page_ = await query.OrderByDescending(r => r.SalesValue).ThenBy(r => r.Name)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); .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. // "SELECT DISTINCT ... WHERE outer.col" derived table EF generates inside a GroupBy.
var ids = page_.Select(r => r.FK_ItemId).ToList(); 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)) .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() .Distinct()
.ToListAsync(); .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, 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); return (rows, total);
} }
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) => 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) 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 }) return await SoldLines(f with { Search = null })
.Where(l => ids.Contains(l.FK_ItemId)) .Where(l => ids.Contains(l.FK_ItemId))
.GroupBy(l => 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); .ToDictionaryAsync(x => x.Key, x => x.Qty);
} }
@@ -11,6 +11,7 @@ public interface ISchemeRepository
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId); Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
Task AddRedemptionAsync(Models.SchemeRedemption entity); Task AddRedemptionAsync(Models.SchemeRedemption entity);
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId); Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
Task<bool> HasLockedInvoiceAsync(Guid shopPOId);
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId); Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity); Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
@@ -52,6 +52,10 @@ public class SchemeRepository(AppDBContext context) : ISchemeRepository
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) => public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid shopPOId) =>
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_ShopPOId == 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) => public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
await context.SchemeBundleComponents await context.SchemeBundleComponents
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId); .FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
@@ -383,6 +383,9 @@ public class SchemeService(
if (await repo.HasRedemptionAsync(schemeId, shopPOId)) if (await repo.HasRedemptionAsync(schemeId, shopPOId))
throw new InvalidOperationException("This scheme has already been redeemed against this ShopPO."); 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 items = await shopPORepo.GetActiveItemsByShopPOIdAsync(shopPOId);
var eligibility = EvaluateRule(scheme, items); var eligibility = EvaluateRule(scheme, items);
if (!eligibility.Eligible) 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") b.Property<Guid>("CreatedBy")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<decimal>("DiscountAmount")
.HasColumnType("decimal(65,30)");
b.Property<Guid>("FK_ShopPOId") b.Property<Guid>("FK_ShopPOId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -887,6 +890,7 @@ namespace Distribution.Migrations
Id = new Guid("34500000-0000-0000-0000-000000000001"), Id = new Guid("34500000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0, 0, DateTimeKind.Unspecified),
CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"), CreatedBy = new Guid("29000000-0000-0000-0000-000000000002"),
DiscountAmount = 0m,
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000001"),
FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified), FinalizedAt = new DateTime(2026, 8, 25, 9, 0, 0, 0, DateTimeKind.Unspecified),
FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"), FinalizedBy = new Guid("29000000-0000-0000-0000-000000000001"),
@@ -902,6 +906,7 @@ namespace Distribution.Migrations
Id = new Guid("34500000-0000-0000-0000-000000000002"), Id = new Guid("34500000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"), CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
DiscountAmount = 0m,
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"), FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
IsActive = true, IsActive = true,
PaymentStatus = 1, PaymentStatus = 1,
+4
View File
@@ -19,6 +19,10 @@ namespace Distribution.Models
public DateTime? FinalizedAt { get; set; } public DateTime? FinalizedAt { get; set; }
public Guid? FinalizedBy { 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 decimal TotalAmount { get; set; }
public ICollection<InvoiceItem> Items { get; set; } = new List<InvoiceItem>(); 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 { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports' import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts' import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
import { RegionSalesPie } from './region-sales-pie'
import { SalesTrendInteractive } from './sales-trend-interactive'
export interface DateRange { export interface DateRange {
from: string // yyyy-MM-dd from: string // yyyy-MM-dd
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
function LoadingGrid() { function LoadingGrid() {
return ( return (
<div className="flex flex-col gap-3" aria-busy="true"> <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"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)} {Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
if (loading || productsLoading || !sales) return <LoadingGrid /> if (loading || productsLoading || !sales) return <LoadingGrid />
const { current: c, previous: p } = sales.kpis const { current: c, previous: p } = sales.kpis
const { current: cash, previous: cashPrev } = sales.cash
const kpi = (title: string, key: keyof SalesKpis, money = true) => ( const kpi = (title: string, key: keyof SalesKpis, money = true) => (
<Kpi <Kpi
title={title} 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 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) const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
return ( return (
<div className="flex flex-col gap-3"> <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('Net Sales', 'netSales')}
{kpi('Gross Sales', 'grossSales')} {kpi('Gross Sales', 'grossSales')}
{kpi('Total Orders', 'orders', false)} {kpi('Total Orders', 'orders', false)}
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
} }
/> />
{kpi('Sales Returns', 'returnValue')} {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> </div>
<p className="text-[11px] text-muted-foreground"> <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. Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet. 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> </p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <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"> <SalesTrendInteractive className="md:col-span-2" />
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
</ChartCard>
<ChartCard title="Sales by Category" note="Share of gross sales"> <ChartCard title="Sales by Category" note="Share of gross sales">
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." /> <DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
</ChartCard> </ChartCard>
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
])} ])}
/> />
</ChartCard> </ChartCard>
<ChartCard title="Sales by Region"> <RegionSalesPie rows={sales.byRegion} />
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
</ChartCard>
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2"> <ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
<MultiAreaChart <MultiAreaChart
@@ -18,7 +18,7 @@ import {
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import { useAuth } from '@/contexts/auth-context' import { useAuth } from '@/contexts/auth-context'
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' import type { StockMovementResponse } from '@/types/api'
import { InventorySection, SalesSection, type DateRange } from './analytics-sections' import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts' import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' }, { 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: '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: '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: '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: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' }, { id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
const DEFAULT_ACTIVE = [ const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus', 'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus', 'deliveryTasks', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart', 'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
] ]
@@ -153,7 +152,6 @@ interface DashboardData {
confirmedSheetsCount: number confirmedSheetsCount: number
pendingTasksCount: number pendingTasksCount: number
partialTasksCount: number partialTasksCount: number
lowStock: LowStockItemResponse[]
movements: StockMovementResponse[] movements: StockMovementResponse[]
warehouseBalances: WarehouseBalanceRow[] warehouseBalances: WarehouseBalanceRow[]
outstandingAmount: number outstandingAmount: number
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
openRides: [], openRides: [],
plannedSheetsCount: 0, confirmedSheetsCount: 0, plannedSheetsCount: 0, confirmedSheetsCount: 0,
pendingTasksCount: 0, partialTasksCount: 0, pendingTasksCount: 0, partialTasksCount: 0,
lowStock: [], movements: [], warehouseBalances: [], movements: [], warehouseBalances: [],
outstandingAmount: 0, outstandingInvoiceCount: 0, outstandingAmount: 0, outstandingInvoiceCount: 0,
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0, draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [], 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')) { if (activeIds.has('stockMovements')) {
fetches.push( fetches.push(
stock.movements({ pageSize: 5 }) 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 className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
</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': case 'stockMovements':
return data.movements.length === 0 ? ( return data.movements.length === 0 ? (
<p className="text-xs text-muted-foreground">No recent stock movements.</p> <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"> <div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} /> <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="Paid" value={fmtAmount(paidAmount)} />
<Stat label="Balance" value={fmtAmount(balance)} /> <Stat label="Balance" value={fmtAmount(balance)} />
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} /> <Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
+9
View File
@@ -163,6 +163,7 @@ export const productReports = {
export interface SalesKpis { export interface SalesKpis {
grossSales: number grossSales: number
returnValue: number returnValue: number
discounts: number
netSales: number netSales: number
orders: number orders: number
averageOrderValue: number averageOrderValue: number
@@ -170,11 +171,19 @@ export interface SalesKpis {
grossProfitMarginPercent: number | null grossProfitMarginPercent: number | null
} }
export interface CashKpis {
collected: number
outstanding: number
overdue: number
collectionRatePercent: number | null
}
export interface DashboardSalesSummary { export interface DashboardSalesSummary {
from: string from: string
to: string to: string
granularity: 'day' | 'month' granularity: 'day' | 'month'
kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string } kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string }
cash: { current: CashKpis; previous: CashKpis }
trend: Array<{ period: string; sales: number; returns: number }> trend: Array<{ period: string; sales: number; returns: number }>
byCategory: Array<{ id: string | null; name: string; amount: number }> byCategory: Array<{ id: string | null; name: string; amount: number }>
byRegion: Array<{ id: string | null; name: string; amount: number }> byRegion: Array<{ id: string | null; name: string; amount: number }>
+1
View File
@@ -1045,6 +1045,7 @@ export interface InvoiceResponse {
finalizedAt: string | null finalizedAt: string | null
finalizedBy: string | null finalizedBy: string | null
totalAmount: number totalAmount: number
discountAmount: number
paidAmount: number paidAmount: number
isActive: boolean isActive: boolean
createdAt: string createdAt: string