Initial commit
This commit is contained in:
@@ -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><select></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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user