Refactor/main flow #54

Merged
DeepnaPooja merged 16 commits from refactor/main_flow into dev 2026-10-07 10:46:59 +05:30
11 changed files with 649 additions and 0 deletions
Showing only changes of commit ff6f3d3218 - Show all commits
@@ -0,0 +1,18 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record SalesAggregate(Guid ItemId, string Sku, string Name, decimal? CostPrice,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled, decimal SalesValue);
public record ReturnAggregate(Guid ItemId, string Sku, string Name, ReturnReasonCode Reason,
decimal Qty, decimal Value);
public interface IProductReportRepository
{
Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize);
Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize);
Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f);
Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds);
Task<decimal> GetTotalSalesAsync(ProductReportFilter f);
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ProductReport;
[ApiController]
[Route("api/reports/products")]
public class ProductReportController(ProductReportService service) : ControllerBase
{
private IActionResult? Check(ProductReportFilter f, int page, int pageSize)
{
var error = ProductReportService.Validate(f, page, pageSize);
return error is null ? null : BadRequest(new { message = error });
}
[HttpGet("stock")]
public async Task<IActionResult> Stock(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? warehouseId, [FromQuery] bool activeOnly = true, [FromQuery] bool lowStockOnly = false,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, warehouseId, null, null, null,
null, null, activeOnly, lowStockOnly, search);
return Check(f, page, pageSize) ?? Ok(await service.GetStockAsync(f, page, pageSize));
}
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetSalesAsync(f, page, pageSize));
}
[HttpGet("returns")]
public async Task<IActionResult> Returns(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId,
[FromQuery] Guid? regionId, [FromQuery] Guid? routeId, [FromQuery] Guid? storeId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] string? search = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var f = new ProductReportFilter(brandId, categoryId, subCategoryId, null, regionId, routeId, storeId,
from, to, false, false, search);
return Check(f, page, pageSize) ?? Ok(await service.GetReturnsAsync(f, page, pageSize));
}
[HttpGet("summary")]
public async Task<IActionResult> Summary(
[FromQuery] Guid? brandId, [FromQuery] Guid? categoryId, [FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = new ProductReportFilter(brandId, categoryId, null, warehouseId, null, null, null,
from, to, true, false, null);
return Check(f, 1, 1) ?? Ok(await service.GetSummaryAsync(f));
}
}
@@ -0,0 +1,26 @@
using Distribution.Models;
namespace Distribution.Features.ProductReport;
public record ProductStockRow(
Guid ItemId, string Sku, string Name, string? Brand, string Category, string? SubCategory, string Uom,
decimal? SalePrice, decimal? CostPrice, decimal? ReorderLevel,
decimal WarehouseQty, decimal VehicleQty, decimal TotalQty, decimal StockValue,
bool IsLowStock, bool IsActive);
public record ProductSalesRow(
Guid ItemId, string Sku, string Name,
int Orders, int Stores, decimal QtyOrdered, decimal QtyFulfilled,
decimal SalesValue, decimal? CostValue, decimal? Margin, decimal? MarginPercent);
public record ProductReturnRow(
Guid ItemId, string Sku, string Name,
decimal ReturnedQty, decimal ReturnedValue, decimal SoldQty, decimal? ReturnPercent,
Dictionary<ReturnReasonCode, decimal> QtyByReason);
public record ProductReportSummary(int ItemCount, decimal TotalQty, decimal TotalValue, decimal TotalSales);
public record ProductReportFilter(
Guid? BrandId, Guid? CategoryId, Guid? SubCategoryId, Guid? WarehouseId,
Guid? RegionId, Guid? RouteId, Guid? StoreId,
DateTime? From, DateTime? To, bool ActiveOnly, bool LowStockOnly, string? Search);
@@ -0,0 +1,156 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ProductReport;
public class ProductReportRepository(AppDBContext db) : IProductReportRepository
{
public async Task<(List<ProductStockRow> Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var balances = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
balances = balances.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
var items = db.Items.AsNoTracking().AsQueryable();
if (f.ActiveOnly) items = items.Where(i => i.IsActive);
if (f.BrandId.HasValue) items = items.Where(i => i.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) items = items.Where(i => i.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) items = items.Where(i => i.FK_SubCategoryId == f.SubCategoryId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
items = items.Where(i => i.Name.Contains(s) || i.Sku.Contains(s));
}
var withQty = items.Select(i => new
{
Item = i,
Wh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Warehouse)
.Sum(b => b.Quantity),
Veh = balances.Where(b => b.FK_ItemId == i.Id && b.LocationType == LocationType.Vehicle)
.Sum(b => b.Quantity),
});
if (f.LowStockOnly)
withQty = withQty.Where(x => x.Item.ReorderLevel != null && x.Wh <= x.Item.ReorderLevel);
var total = await withQty.CountAsync();
var page_ = await withQty
.OrderBy(x => x.Item.Name)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.Item.Id, x.Item.Sku, x.Item.Name,
Brand = x.Item.Brand != null ? x.Item.Brand.Name : null,
Category = x.Item.Category.Name,
SubCategory = x.Item.SubCategory != null ? x.Item.SubCategory.Name : null,
Uom = x.Item.BaseUom.Name,
x.Item.SalePrice, x.Item.CostPrice, x.Item.ReorderLevel, x.Item.IsActive,
x.Wh, x.Veh,
})
.ToListAsync();
var rows = page_.Select(r => new ProductStockRow(
r.Id, r.Sku, r.Name, r.Brand, r.Category, r.SubCategory, r.Uom,
r.SalePrice, r.CostPrice, r.ReorderLevel,
r.Wh, r.Veh, r.Wh + r.Veh, (r.Wh + r.Veh) * (r.CostPrice ?? 0m),
r.ReorderLevel != null && r.Wh <= r.ReorderLevel,
r.IsActive)).ToList();
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)
{
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))
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 (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
lines = lines.Where(l => l.Item.Name.Contains(s) || l.Item.Sku.Contains(s));
}
return lines;
}
public async Task<(List<SalesAggregate> Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var query = SoldLines(f)
.GroupBy(l => new { l.FK_ItemId, l.Item.Sku, l.Item.Name, l.Item.CostPrice })
.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),
});
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
// "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)
.Where(l => ids.Contains(l.FK_ItemId))
.Select(l => new { l.FK_ItemId, l.ShopPO.FK_StoreId })
.Distinct()
.ToListAsync();
var storeCounts = storePairs.GroupBy(p => p.FK_ItemId).ToDictionary(g => g.Key, g => g.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();
return (rows, total);
}
public Task<decimal> GetTotalSalesAsync(ProductReportFilter f) =>
SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice);
public async Task<Dictionary<Guid, decimal>> GetSoldQtyAsync(ProductReportFilter f, IEnumerable<Guid> itemIds)
{
var ids = itemIds.ToList();
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) })
.ToDictionaryAsync(x => x.Key, x => x.Qty);
}
public Task<List<ReturnAggregate>> GetReturnsAsync(ProductReportFilter f)
{
var q = db.SaleReturnItems.AsNoTracking().AsQueryable();
if (f.From.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt >= f.From.Value);
if (f.To.HasValue) q = q.Where(r => r.SaleReturn.CreatedAt < f.To.Value.Date.AddDays(1));
if (f.BrandId.HasValue) q = q.Where(r => r.Item.FK_BrandId == f.BrandId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
if (f.SubCategoryId.HasValue) q = q.Where(r => r.Item.FK_SubCategoryId == f.SubCategoryId.Value);
if (f.StoreId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.FK_StoreId == f.StoreId.Value);
if (f.RouteId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.FK_RouteId == f.RouteId.Value);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (!string.IsNullOrWhiteSpace(f.Search))
{
var s = f.Search.Trim();
q = q.Where(r => r.Item.Name.Contains(s) || r.Item.Sku.Contains(s));
}
return q.GroupBy(r => new { r.FK_ItemId, r.Item.Sku, r.Item.Name, r.ReasonCode })
.Select(g => new ReturnAggregate(g.Key.FK_ItemId, g.Key.Sku, g.Key.Name, g.Key.ReasonCode,
g.Sum(x => x.Quantity), g.Sum(x => x.Quantity * x.UnitPrice)))
.ToListAsync();
}
}
@@ -0,0 +1,72 @@
using Distribution.Features.Stock;
namespace Distribution.Features.ProductReport;
public class ProductReportService(IProductReportRepository repo)
{
public const int MaxRangeDays = 366;
public static string? Validate(ProductReportFilter f, int page, int pageSize)
{
if (page < 1 || pageSize < 1 || pageSize > 200) return "page must be >= 1 and pageSize between 1 and 200.";
if (f.From.HasValue && f.To.HasValue)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To.Value - f.From.Value).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
}
return null;
}
public async Task<PagedResponse<ProductStockRow>> GetStockAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetStockAsync(f, page, pageSize);
return new PagedResponse<ProductStockRow>(page, pageSize, total, rows);
}
public async Task<PagedResponse<ProductSalesRow>> GetSalesAsync(ProductReportFilter f, int page, int pageSize)
{
var (rows, total) = await repo.GetSalesAsync(f, page, pageSize);
var mapped = rows.Select(r =>
{
decimal? cost = r.CostPrice.HasValue ? r.CostPrice.Value * r.QtyFulfilled : null;
decimal? margin = cost.HasValue ? r.SalesValue - cost.Value : null;
decimal? pct = margin.HasValue && r.SalesValue != 0 ? Math.Round(margin.Value / r.SalesValue * 100, 2) : null;
return new ProductSalesRow(r.ItemId, r.Sku, r.Name, r.Orders, r.Stores, r.QtyOrdered, r.QtyFulfilled,
r.SalesValue, cost, margin, pct);
}).ToList();
return new PagedResponse<ProductSalesRow>(page, pageSize, total, mapped);
}
public async Task<PagedResponse<ProductReturnRow>> GetReturnsAsync(ProductReportFilter f, int page, int pageSize)
{
var agg = await repo.GetReturnsAsync(f);
var grouped = agg.GroupBy(a => new { a.ItemId, a.Sku, a.Name })
.Select(g => new
{
g.Key,
Qty = g.Sum(x => x.Qty),
Value = g.Sum(x => x.Value),
ByReason = g.ToDictionary(x => x.Reason, x => x.Qty),
})
.OrderByDescending(x => x.Value).ThenBy(x => x.Key.Name)
.ToList();
var pageRows = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var sold = await repo.GetSoldQtyAsync(f, pageRows.Select(r => r.Key.ItemId));
var rows = pageRows.Select(r =>
{
var soldQty = sold.GetValueOrDefault(r.Key.ItemId);
decimal? pct = soldQty > 0 ? Math.Round(r.Qty / soldQty * 100, 2) : null;
return new ProductReturnRow(r.Key.ItemId, r.Key.Sku, r.Key.Name, r.Qty, r.Value, soldQty, pct, r.ByReason);
}).ToList();
return new PagedResponse<ProductReturnRow>(page, pageSize, grouped.Count, rows);
}
public async Task<ProductReportSummary> GetSummaryAsync(ProductReportFilter f)
{
var (rows, total) = await repo.GetStockAsync(f, 1, int.MaxValue);
var totalSales = await repo.GetTotalSalesAsync(f);
return new ProductReportSummary(total, rows.Sum(r => r.TotalQty), rows.Sum(r => r.StockValue), totalSales);
}
}
+3
View File
@@ -30,6 +30,7 @@ using Distribution.Features.Sync;
using Distribution.Features.Uom;
using Distribution.Features.User;
using Distribution.Features.Vehicle;
using Distribution.Features.ProductReport;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
using Distribution.Features.Warehouse;
@@ -62,6 +63,8 @@ builder.Services.AddScoped<IBrandRepository, BrandRepository>();
builder.Services.AddScoped<IUomRepository, UomRepository>();
builder.Services.AddScoped<IItemRepository, ItemRepository>();
builder.Services.AddScoped<IStockRepository, StockRepository>();
builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
builder.Services.AddScoped<ProductReportService>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
@@ -0,0 +1,213 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { getApiError, getPaginatedItems } from '@/lib/api/index'
import {
productReports,
type ProductReportSummary,
type ProductReturnRow,
type ProductSalesRow,
type ProductStockRow,
} from '@/lib/api/reports'
type Tab = 'stock' | 'sales' | 'returns'
const TABS: { key: Tab; label: string }[] = [
{ key: 'stock', label: 'Stock' },
{ key: 'sales', label: 'Sales' },
{ key: 'returns', label: 'Returns' },
]
const num = (v: number | null | undefined) =>
v == null ? '—' : v.toLocaleString('en-IN', { maximumFractionDigits: 2 })
const pct = (v: number | null | undefined) => (v == null ? '—' : `${v.toFixed(2)}%`)
const stockColumns: TableConfig<ProductStockRow>['columns'] = [
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
{ key: 'brand', title: 'Brand', fields: [{ key: 'brand', transform: v => (v as string | null) ?? '—' }], width: 140 },
{ key: 'category', title: 'Category', fields: ['category'], width: 150 },
{ key: 'uom', title: 'UOM', fields: ['uom'], width: 80 },
{ key: 'warehouseQty', title: 'Warehouse', fields: [{ key: 'warehouseQty', transform: v => num(v as number) }], width: 110 },
{ key: 'vehicleQty', title: 'Vehicles', fields: [{ key: 'vehicleQty', transform: v => num(v as number) }], width: 100 },
{ key: 'totalQty', title: 'Total', fields: [{ key: 'totalQty', transform: v => num(v as number) }], width: 100 },
{ key: 'stockValue', title: 'Stock Value', fields: [{ key: 'stockValue', transform: v => num(v as number) }], width: 120 },
]
const salesColumns: TableConfig<ProductSalesRow>['columns'] = [
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
{ key: 'orders', title: 'Orders', fields: ['orders'], width: 90 },
{ key: 'stores', title: 'Stores', fields: ['stores'], width: 90 },
{ key: 'qtyOrdered', title: 'Qty Ordered', fields: [{ key: 'qtyOrdered', transform: v => num(v as number) }], width: 120 },
{ key: 'qtyFulfilled', title: 'Qty Fulfilled', fields: [{ key: 'qtyFulfilled', transform: v => num(v as number) }], width: 120 },
{ key: 'salesValue', title: 'Sales', fields: [{ key: 'salesValue', transform: v => num(v as number) }], width: 120 },
{ key: 'margin', title: 'Margin', fields: [{ key: 'margin', transform: v => num(v as number | null) }], width: 110 },
{ key: 'marginPercent', title: 'Margin %', fields: [{ key: 'marginPercent', transform: v => pct(v as number | null) }], width: 100 },
]
const returnsColumns: TableConfig<ProductReturnRow>['columns'] = [
{ key: 'name', title: 'Item', fields: [{ key: 'name', primary: true }, { key: 'sku' }], minWidth: 220 },
{ key: 'returnedQty', title: 'Returned Qty', fields: [{ key: 'returnedQty', transform: v => num(v as number) }], width: 120 },
{ key: 'returnedValue', title: 'Returned Value', fields: [{ key: 'returnedValue', transform: v => num(v as number) }], width: 130 },
{ key: 'soldQty', title: 'Sold Qty', fields: [{ key: 'soldQty', transform: v => num(v as number) }], width: 100 },
{ key: 'returnPercent', title: 'Return %', fields: [{ key: 'returnPercent', transform: v => pct(v as number | null) }], width: 100 },
{
key: 'qtyByReason',
title: 'By Reason',
fields: ['qtyByReason'],
minWidth: 220,
cellRender: row => (
<span className="text-sm">
{Object.entries(row.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', ')}
</span>
),
},
]
export function ProductReportClient() {
const [tab, setTab] = useState<Tab>('stock')
const [search, setSearch] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [lowStockOnly, setLowStockOnly] = useState(false)
const [stockData, setStockData] = useState<ProductStockRow[]>([])
const [salesData, setSalesData] = useState<ProductSalesRow[]>([])
const [returnsData, setReturnsData] = useState<ProductReturnRow[]>([])
const [summary, setSummary] = useState<ProductReportSummary | null>(null)
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const fetchData = useCallback(async () => {
setLoading(true)
const base = { page, pageSize, search: search || undefined, from: from || undefined, to: to || undefined }
try {
if (tab === 'stock') {
const res = await productReports.stock({ ...base, lowStockOnly })
setStockData(getPaginatedItems(res))
setTotal(res.total)
} else if (tab === 'sales') {
const res = await productReports.sales(base)
setSalesData(getPaginatedItems(res))
setTotal(res.total)
} else {
const res = await productReports.returns(base)
setReturnsData(getPaginatedItems(res))
setTotal(res.total)
}
} catch (err) {
toast.add({ title: 'Failed to load product report', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [tab, page, pageSize, search, from, to, lowStockOnly])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
productReports
.summary({ from: from || undefined, to: to || undefined })
.then(setSummary)
.catch(err => toast.add({ title: 'Failed to load summary', description: getApiError(err), type: 'error' }))
}, [from, to])
function changeTab(next: Tab) {
setTab(next)
setPage(1)
setTotal(0)
}
const common = { serverSide: true, stickyHeader: true, pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] } }
const stockConfig = useMemo<TableConfig<ProductStockRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: stockColumns }),
[pageSize],
)
const salesConfig = useMemo<TableConfig<ProductSalesRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No finalized sales in this period.', columns: salesColumns }),
[pageSize],
)
const returnsConfig = useMemo<TableConfig<ProductReturnRow>>(
() => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No returns in this period.', columns: returnsColumns }),
[pageSize],
)
const onServerSideChange = ({ page: p }: { page: { page: number; pageSize: number } }) => {
setPage(p.page)
setPageSize(p.pageSize)
}
return (
<>
<div>
<h1 className="text-2xl font-semibold text-foreground">Product Report</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stock, sales and returns per product. Sales count fulfilled quantity on finalized invoices.
</p>
</div>
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
['Products', num(summary.itemCount)],
['Total stock qty', num(summary.totalQty)],
['Stock value', num(summary.totalValue)],
['Sales (period)', num(summary.totalSales)],
].map(([label, value]) => (
<Card key={label} className="rounded-3xl border-border/60">
<CardContent className="space-y-1">
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardContent>
</Card>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{TABS.map(t => (
<Button key={t.key} size="sm" variant={tab === t.key ? 'default' : 'outline'} onClick={() => changeTab(t.key)}>
{t.label}
</Button>
))}
<Input
className="w-56"
placeholder="Search name or SKU"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
/>
{tab !== 'stock' ? (
<>
<Input className="w-40" type="date" value={from} onChange={e => { setFrom(e.target.value); setPage(1) }} />
<Input className="w-40" type="date" value={to} onChange={e => { setTo(e.target.value); setPage(1) }} />
</>
) : (
<Button size="sm" variant={lowStockOnly ? 'default' : 'outline'} onClick={() => { setLowStockOnly(v => !v); setPage(1) }}>
Low stock only
</Button>
)}
</div>
{tab === 'stock' && (
<DataTable config={stockConfig} data={stockData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
{tab === 'sales' && (
<DataTable config={salesConfig} data={salesData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
{tab === 'returns' && (
<DataTable config={returnsConfig} data={returnsData} loading={loading} totalCount={total} onServerSideChange={onServerSideChange} />
)}
</>
)
}
@@ -0,0 +1,5 @@
import { ProductReportClient } from './_components/product-report-client'
export default function ProductReportPage() {
return <ProductReportClient />
}
+9
View File
@@ -102,6 +102,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Stock Movements', href: '/stock/movements', icon: PackageMoving01Icon, description: 'Full history of stock in and out across all locations', permission: 'warehouses.view' },
],
},
{
title: 'Procurement',
icon: ShoppingCart01Icon,
@@ -161,6 +162,14 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Warehouses', href: '/network/warehouses', icon: Building02Icon, description: 'Storage locations where stock is held and loaded from', permission: 'warehouses.view' },
],
},
{
title: 'Reports',
icon: ClipboardListIcon,
items: [
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
],
},
]
// Ordered candidates for mobile bottom nav — first 5 visible are shown
+1
View File
@@ -8,6 +8,7 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock'
export { productReports } from './reports'
export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS } from './sys-config'
+87
View File
@@ -0,0 +1,87 @@
import { api } from '@/lib/api'
import type { PaginatedResponse } from '@/types/api'
import type { PageParams } from './master'
export interface ProductStockRow {
itemId: string
sku: string
name: string
brand: string | null
category: string
subCategory: string | null
uom: string
salePrice: number | null
costPrice: number | null
reorderLevel: number | null
warehouseQty: number
vehicleQty: number
totalQty: number
stockValue: number
isLowStock: boolean
isActive: boolean
}
export interface ProductSalesRow {
itemId: string
sku: string
name: string
orders: number
stores: number
qtyOrdered: number
qtyFulfilled: number
salesValue: number
costValue: number | null
margin: number | null
marginPercent: number | null
}
export interface ProductReturnRow {
itemId: string
sku: string
name: string
returnedQty: number
returnedValue: number
soldQty: number
returnPercent: number | null
qtyByReason: Record<string, number>
}
export interface ProductReportSummary {
itemCount: number
totalQty: number
totalValue: number
totalSales: number
}
export interface ProductReportParams extends PageParams {
brandId?: string
categoryId?: string
subCategoryId?: string
warehouseId?: string
regionId?: string
routeId?: string
storeId?: string
from?: string
to?: string
search?: string
}
export interface ProductStockParams extends ProductReportParams {
activeOnly?: boolean
lowStockOnly?: boolean
}
export const productReports = {
stock: (params?: ProductStockParams) =>
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
sales: (params?: ProductReportParams) =>
api.get<PaginatedResponse<ProductSalesRow>>('/reports/products/sales', { params }).then(r => r.data),
returns: (params?: ProductReportParams) =>
api.get<PaginatedResponse<ProductReturnRow>>('/reports/products/returns', { params }).then(r => r.data),
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data),
}