Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,849 @@
using AutoMapper;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
{
/// <summary>
/// Manages the pre-priced service catalog: categories, items, revenue/COGS account mapping, and PDF export.
/// Items in the catalog serve as reusable line items on quotes and jobs; the <c>IsMerchandise</c> flag
/// additionally makes them available as retail goods on invoices. Revenue and COGS accounts are only
/// populated when the company's subscription includes accounting features
/// (<c>AllowAccounting</c> middleware flag), so the dropdowns degrade gracefully to empty lists.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageProducts)]
public class CatalogItemsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<CatalogItemsController> _logger;
private readonly IPdfService _pdfService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
public CatalogItemsController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILogger<CatalogItemsController> logger,
IPdfService pdfService,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_pdfService = pdfService;
_userManager = userManager;
_tenantContext = tenantContext;
_measurementService = measurementService;
_subscriptionService = subscriptionService;
}
/// <summary>
/// Displays the catalog item list grouped in a nested category hierarchy. Loads all categories with
/// their items in one query, then filters and re-builds the tree in memory so that empty parent
/// categories are suppressed when a search or category filter is active — giving users a tidy
/// filtered view without confusing empty headings. Stats (count, average price) are computed
/// from the unfiltered set so dashboard cards always reflect the full catalog, not just the
/// current page.
/// </summary>
public async Task<IActionResult> Index(int? categoryId, string? searchTerm)
{
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
allItems = allItems.Where(i =>
i.Name.ToLower().Contains(search) ||
(i.SKU != null && i.SKU.ToLower().Contains(search)) ||
(i.Description != null && i.Description.ToLower().Contains(search))).ToList();
}
// Apply category filter
if (categoryId.HasValue)
{
allItems = allItems.Where(i => i.CategoryId == categoryId.Value).ToList();
}
// Build hierarchical category structure with filtered items
var rootCategories = BuildCategoryHierarchy(allCategories, null, allItems, searchTerm, categoryId);
// Get categories for filter dropdown
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(allCategories, null, hierarchicalList);
var categoryList = hierarchicalList
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = GetCategoryDisplayName(c, allCategories)
})
.ToList();
// View data
ViewBag.Categories = categoryList;
ViewBag.CurrentCategoryId = categoryId;
ViewBag.SearchTerm = searchTerm;
ViewBag.HasFilters = !string.IsNullOrWhiteSpace(searchTerm) || categoryId.HasValue;
// Stats for cards — enumerate once to avoid repeated SelectMany passes
var allItemsForStats = allCategories.SelectMany(c => c.Items).ToList();
ViewBag.TotalItemsCount = allItemsForStats.Count;
ViewBag.ActiveItemsCount = allItemsForStats.Count(i => i.IsActive);
ViewBag.AveragePrice = allItemsForStats.Count > 0 ? allItemsForStats.Average(i => i.DefaultPrice) : 0;
ViewBag.CategoryCount = allCategories.Count;
ViewBag.FilteredItemsCount = allItems.Count;
return View(rootCategories);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog items");
TempData["Error"] = $"An error occurred while loading catalog items: {ex.Message}";
// Set default ViewBag values to prevent view errors
ViewBag.Categories = new List<SelectListItem>();
ViewBag.CurrentCategoryId = categoryId;
ViewBag.SearchTerm = searchTerm;
ViewBag.HasFilters = false;
ViewBag.TotalItemsCount = 0;
ViewBag.ActiveItemsCount = 0;
ViewBag.AveragePrice = 0;
ViewBag.CategoryCount = 0;
ViewBag.FilteredItemsCount = 0;
return View(new List<CategoryWithItems>());
}
}
/// <summary>
/// Shows full detail for a single catalog item including its category, revenue account, and COGS
/// account. Both accounts are eagerly loaded here so the view can display human-readable account
/// names without a second round-trip.
/// </summary>
public async Task<IActionResult> Details(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category, i => i.RevenueAccount, i => i.CogsAccount);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var itemDto = _mapper.Map<CatalogItemDto>(item);
return View(itemDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId}", id);
TempData["Error"] = "An error occurred while loading the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the Create form, pre-selecting the given category when navigating from a category page.
/// Enforces the subscription plan catalog-item limit before rendering — redirecting with an
/// explanatory message rather than showing a form the user cannot successfully submit. Area-unit
/// labels (sqft vs m²) are resolved from the company's metric preference so field hints match
/// the user's locale.
/// </summary>
public async Task<IActionResult> Create(int? categoryId)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCatalogItemAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
TempData["Error"] = $"You have reached your plan limit of {max} catalog items. " +
"Please upgrade your plan to add more items.";
return RedirectToAction(nameof(Index));
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels based on company preference
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var model = new CreateCatalogItemDto
{
CategoryId = categoryId ?? 0,
DisplayOrder = 0
};
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create catalog item page");
TempData["Error"] = "An error occurred while loading the page.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Persists a new catalog item after re-checking the subscription limit on POST. The limit is
/// checked twice (GET and POST) to close the race window where a user could open the form under
/// the limit, then another admin creates items pushing the company over before this POST lands.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCatalogItemDto dto)
{
try
{
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
if (ModelState.IsValid)
{
// Subscription catalog item limit check
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.CanAddCatalogItemAsync(companyId))
{
var (used, max) = await _subscriptionService.GetCatalogItemCountAsync(companyId);
ModelState.AddModelError(string.Empty,
$"You have reached your plan limit of {max} catalog items. " +
"Please upgrade your plan to add more items.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
return View(dto);
}
var item = _mapper.Map<CatalogItem>(dto);
await _unitOfWork.CatalogItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' created successfully.";
return RedirectToAction(nameof(Index));
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating catalog item");
ModelState.AddModelError("", "An error occurred while creating the catalog item.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
}
/// <summary>
/// Loads the Edit form for an existing catalog item. Verbose debug-level logging is intentional
/// here because catalog item edits occasionally failed silently in early development; the
/// granular log messages help triage mapping or dropdown-population issues without needing a
/// debugger attached to production.
/// </summary>
public async Task<IActionResult> Edit(int id)
{
try
{
_logger.LogInformation("Loading catalog item {ItemId} for editing", id);
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
_logger.LogWarning("Catalog item {ItemId} not found", id);
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
_logger.LogDebug("Mapping item {ItemId} to DTO", id);
var dto = _mapper.Map<UpdateCatalogItemDto>(item);
_logger.LogDebug("Populating category dropdown for item {ItemId}", id);
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels based on company preference
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
_logger.LogInformation("Successfully loaded item {ItemId} for editing", id);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId} for editing. Message: {Message}, StackTrace: {StackTrace}",
id, ex.Message, ex.StackTrace);
TempData["Error"] = $"An error occurred while loading the catalog item: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Applies edits to an existing catalog item. Uses AutoMapper's map-onto-existing-entity overload
/// (<c>_mapper.Map(dto, item)</c>) so that EF Core's change tracker detects only the modified
/// columns, rather than replacing the full entity and potentially overwriting audit fields or
/// columns not present in the DTO. Redirects to Details on success so the user can immediately
/// verify the saved state.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto)
{
if (id != dto.Id)
{
TempData["Error"] = "Invalid catalog item ID.";
return RedirectToAction(nameof(Index));
}
try
{
if (ModelState.IsValid)
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
_mapper.Map(dto, item);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
return RedirectToAction(nameof(Details), new { id = item.Id });
}
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating catalog item {ItemId}", id);
ModelState.AddModelError("", "An error occurred while updating the catalog item.");
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
return View(dto);
}
}
/// <summary>
/// Shows the Delete confirmation page with the item's category name so the user can confirm they
/// are deleting the correct item. Category is eagerly loaded here rather than relying on a prior
/// Details load so the confirmation is authoritative.
/// </summary>
public async Task<IActionResult> Delete(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var dto = _mapper.Map<CatalogItemDto>(item);
return View(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading catalog item {ItemId} for deletion", id);
TempData["Error"] = "An error occurred while loading the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Soft-deletes a catalog item, preserving it in the database so that existing quote and job line
/// items that reference it do not lose their descriptions or pricing history. Physical deletion
/// is intentionally avoided because historical records often depend on the catalog item text.
/// </summary>
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
{
TempData["Error"] = "Catalog item not found.";
return RedirectToAction(nameof(Index));
}
var itemName = item.Name;
await _unitOfWork.CatalogItems.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{itemName}' deleted successfully.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting catalog item {ItemId}", id);
TempData["Error"] = "An error occurred while deleting the catalog item.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// AJAX endpoint that returns active items belonging to a single category, ordered by
/// <c>DisplayOrder</c> then name. Called by the quote/job item wizard when the user picks a
/// category from the dropdown; returns only the fields the wizard needs so the JSON payload is
/// small. Inactive items are excluded so discontinued services don't appear on new work orders.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetItemsByCategory(int categoryId)
{
try
{
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
var itemDtos = items
.OrderBy(i => i.DisplayOrder)
.ThenBy(i => i.Name)
.Select(i => new
{
i.Id,
i.Name,
i.Description,
i.DefaultPrice,
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
})
.ToList();
return Json(itemDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting items for category {CategoryId}", categoryId);
return Json(new { error = "An error occurred while loading catalog items." });
}
}
/// <summary>
/// AJAX endpoint that returns all active merchandise items grouped by category for the invoice
/// merchandise picker. Only items with <c>IsMerchandise = true</c> are returned; this flag
/// distinguishes retail goods (e.g. touch-up spray, accessories) from pure service catalog
/// entries that should not appear as physical line items on an invoice. Includes
/// <c>RevenueAccountId</c> so the invoice create page can pre-populate the GL mapping without
/// a separate lookup.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMerchandiseItems()
{
try
{
var items = await _unitOfWork.CatalogItems.FindAsync(
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
var result = items
.OrderBy(i => i.Category.Name)
.ThenBy(i => i.DisplayOrder)
.ThenBy(i => i.Name)
.Select(i => new
{
i.Id,
i.Name,
i.SKU,
CategoryName = i.Category.Name,
i.DefaultPrice,
i.RevenueAccountId
})
.ToList();
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching merchandise items");
return Json(new { error = "An error occurred while loading merchandise items." });
}
}
/// <summary>
/// AJAX full-text search across item name and SKU, returning at most 20 results ordered
/// alphabetically. The 20-result cap keeps the autocomplete dropdown usable; if a user needs a
/// more precise match they are expected to narrow their search term. Returns an empty list rather
/// than an error when the search term is blank, so the calling JavaScript doesn't need to guard
/// against a null payload.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchItems(string searchTerm)
{
try
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
.Where(i => i.IsActive &&
(i.Name.ToLower().Contains(search) ||
(i.SKU != null && i.SKU.ToLower().Contains(search))))
.OrderBy(i => i.Name)
.Take(20) // Limit results
.Select(i => new
{
i.Id,
i.Name,
i.Description,
CategoryName = i.Category.Name,
i.DefaultPrice,
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
})
.ToList();
return Json(items);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching catalog items with term '{SearchTerm}'", searchTerm);
return Json(new { error = "An error occurred while searching catalog items." });
}
}
/// <summary>
/// AJAX endpoint that fetches the full detail set for a single catalog item so the quote wizard
/// can pre-populate sandblasting, masking, estimated minutes, and approximate area defaults.
/// Separated from <see cref="Details"/> because the quote wizard consumes JSON, not a view, and
/// needs a slightly different field set (camelCase, null-coalesced primitives).
/// </summary>
[HttpGet]
public async Task<IActionResult> GetItemForQuote(int id)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id, false, i => i.Category);
if (item == null)
{
return Json(new { error = "Catalog item not found." });
}
var itemData = new
{
id = item.Id,
name = item.Name,
description = item.Description ?? "",
price = item.DefaultPrice,
requiresSandblasting = item.DefaultRequiresSandblasting,
requiresMasking = item.DefaultRequiresMasking,
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
approximateArea = item.ApproximateArea ?? 0,
categoryName = item.Category.Name
};
return Json(itemData);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting catalog item {ItemId} for quote", id);
return Json(new { error = "An error occurred while loading the catalog item." });
}
}
/// <summary>
/// Populates <c>ViewBag.RevenueAccounts</c> and <c>ViewBag.CogsAccounts</c> for the Create/Edit
/// forms. Returns empty lists when the company's subscription does not include accounting
/// (<c>AllowAccounting</c> is false), so the view can hide the account fields entirely rather
/// than showing broken dropdowns. Revenue and COGS accounts are split so the view can use the
/// appropriate list for each field without client-side filtering.
/// </summary>
private async Task PopulateAccountDropdowns()
{
var allowAccounting = HttpContext.Items["AllowAccounting"] as bool? ?? false;
if (!allowAccounting)
{
ViewBag.RevenueAccounts = new List<SelectListItem>();
ViewBag.CogsAccounts = new List<SelectListItem>();
return;
}
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
var revenueAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
var cogsAccounts = accounts
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.RevenueAccounts = revenueAccounts;
ViewBag.CogsAccounts = cogsAccounts;
}
/// <summary>
/// Populates <c>ViewBag.Categories</c> with a flat list ordered so parents always appear before
/// their children, with indented display names (non-breaking spaces + em dash prefix) to convey
/// depth visually inside a standard HTML <c>&lt;select&gt;</c> element. A proper hierarchical
/// select control would require JavaScript; this simpler approach works without any JS dependency.
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
BuildHierarchicalCategoryList(categories, null, hierarchicalList);
var categoryList = hierarchicalList
.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = GetCategoryDisplayName(c, categories)
})
.ToList();
ViewBag.Categories = categoryList;
}
/// <summary>
/// Recursively walks the category tree depth-first, appending each node to <paramref name="result"/>
/// before its children. This pre-order traversal ensures the flat list respects the parent→child
/// visual ordering required by the indented dropdown approach.
/// </summary>
private void BuildHierarchicalCategoryList(List<CatalogCategory> allCategories, int? parentId, List<CatalogCategory> result)
{
var children = allCategories
.Where(c => c.ParentCategoryId == parentId)
.OrderBy(c => c.Name)
.ToList();
foreach (var category in children)
{
result.Add(category);
// Recursively add children
BuildHierarchicalCategoryList(allCategories, category.Id, result);
}
}
/// <summary>
/// Returns an indented display name for a category using non-breaking spaces and an em dash
/// (<c>\u00A0</c>, <c>\u2014</c>) so hierarchy is visible inside a plain HTML select element.
/// Regular spaces are deliberately avoided because browsers collapse them in option text.
/// </summary>
private string GetCategoryDisplayName(CatalogCategory category, List<CatalogCategory> allCategories)
{
var depth = GetCategoryDepth(category, allCategories);
// Use em dash and non-breaking spaces for better visibility
var prefix = depth > 0 ? new string('\u00A0', depth * 2) + "\u2014 " : "";
return $"{prefix}{category.Name}";
}
/// <summary>
/// Calculates how many levels deep a category sits in the tree by walking parent links.
/// Guards against two malformed data scenarios: circular references (detected via a visited
/// set) and runaway chains (hard-capped at 20 levels). Both edge cases log a warning instead
/// of throwing so a bad seed-data row doesn't take down the whole Index page.
/// </summary>
private int GetCategoryDepth(CatalogCategory category, List<CatalogCategory> allCategories)
{
int depth = 0;
var current = category;
var visited = new HashSet<int> { category.Id };
while (current.ParentCategoryId.HasValue)
{
depth++;
// Check for circular reference
if (visited.Contains(current.ParentCategoryId.Value))
{
_logger.LogWarning("Circular reference detected in category hierarchy at category {CategoryId}", category.Id);
break;
}
current = allCategories.FirstOrDefault(c => c.Id == current.ParentCategoryId.Value);
if (current == null) break;
visited.Add(current.Id);
// Safety limit to prevent infinite loops
if (depth > 20)
{
_logger.LogWarning("Category depth exceeded 20 levels at category {CategoryId}", category.Id);
break;
}
}
return depth;
}
/// <summary>
/// Recursively builds the nested <see cref="CategoryWithItems"/> tree that drives the Index view.
/// When a search or category filter is active, empty categories (no direct items AND no
/// descendant items) are pruned from the result so the view only shows categories that are
/// relevant to the current filter. Without filters, all categories are included even if empty,
/// giving admins full visibility of the catalog structure.
/// </summary>
private List<CategoryWithItems> BuildCategoryHierarchy(
List<CatalogCategory> allCategories,
int? parentId,
List<CatalogItem> filteredItems,
string? searchTerm,
int? categoryFilter)
{
var result = new List<CategoryWithItems>();
var categories = allCategories
.Where(c => c.ParentCategoryId == parentId)
.OrderBy(c => c.Name)
.ToList();
foreach (var category in categories)
{
// Get items for this category
var categoryItems = filteredItems
.Where(i => i.CategoryId == category.Id)
.OrderBy(i => i.Name)
.Select(i => _mapper.Map<CatalogItemListDto>(i))
.ToList();
// Recursively get subcategories
var subCategories = BuildCategoryHierarchy(allCategories, category.Id, filteredItems, searchTerm, categoryFilter);
// Only include category if it has items, subcategories with items, or no filters are applied
var hasItems = categoryItems.Any();
var hasSubCategoriesWithItems = subCategories.Any();
var shouldInclude = hasItems || hasSubCategoriesWithItems || (string.IsNullOrWhiteSpace(searchTerm) && !categoryFilter.HasValue);
if (shouldInclude)
{
result.Add(new CategoryWithItems
{
Category = category,
Items = categoryItems,
SubCategories = subCategories,
TotalItems = categoryItems.Count + subCategories.Sum(s => s.TotalItems)
});
}
}
return result;
}
/// <summary>
/// Generates and streams a PDF of all active catalog items, grouped by category, including the
/// company's logo and branding. Only active items are included so the PDF serves as a
/// customer-facing price sheet rather than an internal admin report. The file name embeds the
/// current date so successive exports are distinguishable without renaming.
/// </summary>
public async Task<IActionResult> ExportCatalogPdf()
{
try
{
// Get current user and company
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser?.CompanyId == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Index));
}
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
if (company == null)
{
TempData["Error"] = "Company information not found.";
return RedirectToAction(nameof(Index));
}
// Get all active catalog items with their categories
var items = await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive,
false,
ci => ci.Category
);
var activeItems = items.OrderBy(i => i.Category.Name).ThenBy(i => i.Name).ToList();
// Group items by category
var itemsByCategory = activeItems
.GroupBy(i => i.Category)
.OrderBy(g => g.Key.Name)
.ToList();
// Generate PDF
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
itemsByCategory,
company.CompanyName,
company.LogoData,
company.LogoContentType
);
// Return PDF file
var fileName = $"Product-Catalog-{DateTime.Now:yyyy-MM-dd}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating catalog PDF");
TempData["Error"] = "An error occurred while generating the PDF.";
return RedirectToAction(nameof(Index));
}
}
}
// Helper class for hierarchical display
public class CategoryWithItems
{
public CatalogCategory Category { get; set; } = null!;
public List<CatalogItemListDto> Items { get; set; } = new();
public List<CategoryWithItems> SubCategories { get; set; } = new();
public int TotalItems { get; set; }
}
}