From ff6f3d3218dd2c2f510867aa7a9298aa409b9111 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Wed, 7 Oct 2026 10:41:04 +0530 Subject: [PATCH] Add product reports --- .../ProductReport/IProductReportRepository.cs | 18 ++ .../ProductReport/ProductReportController.cs | 59 +++++ .../ProductReport/ProductReportDTOs.cs | 26 +++ .../ProductReport/ProductReportRepository.cs | 156 +++++++++++++ .../ProductReport/ProductReportService.cs | 72 ++++++ Distribution/Program.cs | 3 + .../_components/product-report-client.tsx | 213 ++++++++++++++++++ .../app/(dashboard)/reports/products/page.tsx | 5 + distribution_fe/config/nav.ts | 9 + distribution_fe/lib/api/index.ts | 1 + distribution_fe/lib/api/reports.ts | 87 +++++++ 11 files changed, 649 insertions(+) create mode 100644 Distribution/Features/ProductReport/IProductReportRepository.cs create mode 100644 Distribution/Features/ProductReport/ProductReportController.cs create mode 100644 Distribution/Features/ProductReport/ProductReportDTOs.cs create mode 100644 Distribution/Features/ProductReport/ProductReportRepository.cs create mode 100644 Distribution/Features/ProductReport/ProductReportService.cs create mode 100644 distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx create mode 100644 distribution_fe/app/(dashboard)/reports/products/page.tsx create mode 100644 distribution_fe/lib/api/reports.ts diff --git a/Distribution/Features/ProductReport/IProductReportRepository.cs b/Distribution/Features/ProductReport/IProductReportRepository.cs new file mode 100644 index 0000000..2063ca3 --- /dev/null +++ b/Distribution/Features/ProductReport/IProductReportRepository.cs @@ -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 Rows, int Total)> GetStockAsync(ProductReportFilter f, int page, int pageSize); + Task<(List Rows, int Total)> GetSalesAsync(ProductReportFilter f, int page, int pageSize); + Task> GetReturnsAsync(ProductReportFilter f); + Task> GetSoldQtyAsync(ProductReportFilter f, IEnumerable itemIds); + Task GetTotalSalesAsync(ProductReportFilter f); +} diff --git a/Distribution/Features/ProductReport/ProductReportController.cs b/Distribution/Features/ProductReport/ProductReportController.cs new file mode 100644 index 0000000..1d29f42 --- /dev/null +++ b/Distribution/Features/ProductReport/ProductReportController.cs @@ -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 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 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 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 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)); + } +} diff --git a/Distribution/Features/ProductReport/ProductReportDTOs.cs b/Distribution/Features/ProductReport/ProductReportDTOs.cs new file mode 100644 index 0000000..d961568 --- /dev/null +++ b/Distribution/Features/ProductReport/ProductReportDTOs.cs @@ -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 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); diff --git a/Distribution/Features/ProductReport/ProductReportRepository.cs b/Distribution/Features/ProductReport/ProductReportRepository.cs new file mode 100644 index 0000000..a1a75e6 --- /dev/null +++ b/Distribution/Features/ProductReport/ProductReportRepository.cs @@ -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 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 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 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 GetTotalSalesAsync(ProductReportFilter f) => + SoldLines(f).SumAsync(x => x.QuantityFulfilled * x.UnitPrice); + + public async Task> GetSoldQtyAsync(ProductReportFilter f, IEnumerable 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> 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(); + } +} diff --git a/Distribution/Features/ProductReport/ProductReportService.cs b/Distribution/Features/ProductReport/ProductReportService.cs new file mode 100644 index 0000000..b82f990 --- /dev/null +++ b/Distribution/Features/ProductReport/ProductReportService.cs @@ -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> GetStockAsync(ProductReportFilter f, int page, int pageSize) + { + var (rows, total) = await repo.GetStockAsync(f, page, pageSize); + return new PagedResponse(page, pageSize, total, rows); + } + + public async Task> 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(page, pageSize, total, mapped); + } + + public async Task> 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(page, pageSize, grouped.Count, rows); + } + + public async Task 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); + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index f23dfe2..19dc98e 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -30,6 +30,7 @@ using Distribution.Features.Sync; using Distribution.Features.Uom; using Distribution.Features.User; using Distribution.Features.Vehicle; +using Distribution.Features.ProductReport; using Distribution.Features.Stock; using Distribution.Features.SysConfig; using Distribution.Features.Warehouse; @@ -62,6 +63,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx new file mode 100644 index 0000000..369d090 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/products/_components/product-report-client.tsx @@ -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['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['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['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 => ( + + {Object.entries(row.qtyByReason).map(([reason, qty]) => `${reason}: ${num(qty)}`).join(', ')} + + ), + }, +] + +export function ProductReportClient() { + const [tab, setTab] = useState('stock') + const [search, setSearch] = useState('') + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [lowStockOnly, setLowStockOnly] = useState(false) + + const [stockData, setStockData] = useState([]) + const [salesData, setSalesData] = useState([]) + const [returnsData, setReturnsData] = useState([]) + const [summary, setSummary] = useState(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>( + () => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No products found.', columns: stockColumns }), + [pageSize], + ) + const salesConfig = useMemo>( + () => ({ ...common, rowKey: r => r.itemId, emptyMessage: 'No finalized sales in this period.', columns: salesColumns }), + [pageSize], + ) + const returnsConfig = useMemo>( + () => ({ ...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 ( + <> +
+

Product Report

+

+ Stock, sales and returns per product. Sales count fulfilled quantity on finalized invoices. +

+
+ + {summary && ( +
+ {[ + ['Products', num(summary.itemCount)], + ['Total stock qty', num(summary.totalQty)], + ['Stock value', num(summary.totalValue)], + ['Sales (period)', num(summary.totalSales)], + ].map(([label, value]) => ( + + + {label} + {value} + + + ))} +
+ )} + +
+ {TABS.map(t => ( + + ))} + { setSearch(e.target.value); setPage(1) }} + /> + {tab !== 'stock' ? ( + <> + { setFrom(e.target.value); setPage(1) }} /> + { setTo(e.target.value); setPage(1) }} /> + + ) : ( + + )} +
+ + {tab === 'stock' && ( + + )} + {tab === 'sales' && ( + + )} + {tab === 'returns' && ( + + )} + + ) +} diff --git a/distribution_fe/app/(dashboard)/reports/products/page.tsx b/distribution_fe/app/(dashboard)/reports/products/page.tsx new file mode 100644 index 0000000..61a4f8a --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/products/page.tsx @@ -0,0 +1,5 @@ +import { ProductReportClient } from './_components/product-report-client' + +export default function ProductReportPage() { + return +} diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index ffbd064..9d86b5d 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -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 diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 90173a0..ed4c5f6 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -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' diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts new file mode 100644 index 0000000..a3dcdd9 --- /dev/null +++ b/distribution_fe/lib/api/reports.ts @@ -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 +} + +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>('/reports/products/stock', { params }).then(r => r.data), + + sales: (params?: ProductReportParams) => + api.get>('/reports/products/sales', { params }).then(r => r.data), + + returns: (params?: ProductReportParams) => + api.get>('/reports/products/returns', { params }).then(r => r.data), + + summary: (params?: Pick) => + api.get('/reports/products/summary', { params }).then(r => r.data), +}