560a2c76b8
- Skip $0-priced items (placeholders/category headers) in RunAiPriceCheck - Build full category path (e.g. "Cerakote > Firearms") via BuildCategoryPath so Claude receives coating-type context — Cerakote pricing differs significantly from standard powder coat - Update AI system prompt to instruct Claude to use the category path when determining process type, equipment, cure times, and market rates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1123 lines
50 KiB
C#
1123 lines
50 KiB
C#
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.AI;
|
||
using PowderCoating.Application.DTOs.Catalog;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Application.Services;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Enums;
|
||
using PowderCoating.Core.Interfaces;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
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;
|
||
private readonly ICatalogImageService _catalogImageService;
|
||
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
||
|
||
public CatalogItemsController(
|
||
IUnitOfWork unitOfWork,
|
||
IMapper mapper,
|
||
ILogger<CatalogItemsController> logger,
|
||
IPdfService pdfService,
|
||
UserManager<ApplicationUser> userManager,
|
||
ITenantContext tenantContext,
|
||
IMeasurementConversionService measurementService,
|
||
ISubscriptionService subscriptionService,
|
||
ICatalogImageService catalogImageService,
|
||
IAiCatalogPriceCheckService priceCheckService)
|
||
{
|
||
_unitOfWork = unitOfWork;
|
||
_mapper = mapper;
|
||
_logger = logger;
|
||
_pdfService = pdfService;
|
||
_userManager = userManager;
|
||
_tenantContext = tenantContext;
|
||
_measurementService = measurementService;
|
||
_subscriptionService = subscriptionService;
|
||
_catalogImageService = catalogImageService;
|
||
_priceCheckService = priceCheckService;
|
||
}
|
||
|
||
/// <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, IFormFile? image)
|
||
{
|
||
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();
|
||
|
||
// Upload image after save so we have a stable item ID for the blob path.
|
||
if (image != null && image.Length > 0)
|
||
{
|
||
var imgResult = await _catalogImageService.UploadAsync(image, item.Id, companyId, null, null);
|
||
if (imgResult.Success)
|
||
{
|
||
item.ImagePath = imgResult.ImagePath;
|
||
item.ThumbnailPath = imgResult.ThumbnailPath;
|
||
await _unitOfWork.CompleteAsync();
|
||
}
|
||
else
|
||
{
|
||
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
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);
|
||
|
||
ViewBag.CurrentImagePath = item.ImagePath;
|
||
ViewBag.CurrentThumbnailPath = item.ThumbnailPath;
|
||
ViewBag.HasImage = !string.IsNullOrEmpty(item.ImagePath);
|
||
|
||
_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, IFormFile? image, bool removeImage = false)
|
||
{
|
||
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);
|
||
|
||
if (image != null && image.Length > 0)
|
||
{
|
||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||
var imgResult = await _catalogImageService.UploadAsync(
|
||
image, item.Id, companyId, item.ImagePath, item.ThumbnailPath);
|
||
if (imgResult.Success)
|
||
{
|
||
item.ImagePath = imgResult.ImagePath;
|
||
item.ThumbnailPath = imgResult.ThumbnailPath;
|
||
}
|
||
else
|
||
{
|
||
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
|
||
}
|
||
}
|
||
else if (removeImage)
|
||
{
|
||
await _catalogImageService.DeleteAsync(item.ImagePath, item.ThumbnailPath);
|
||
item.ImagePath = null;
|
||
item.ThumbnailPath = null;
|
||
}
|
||
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
|
||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
||
}
|
||
|
||
await PopulateCategoryDropdown();
|
||
await PopulateAccountDropdowns();
|
||
|
||
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();
|
||
|
||
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,
|
||
thumbnailPath = i.ThumbnailPath
|
||
})
|
||
.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,
|
||
thumbnailPath = i.ThumbnailPath
|
||
})
|
||
.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,
|
||
thumbnailPath = item.ThumbnailPath
|
||
};
|
||
|
||
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>
|
||
/// Serves a catalog item image (full-size or thumbnail) from Azure Blob Storage.
|
||
/// Uses plain [Authorize] (not the class-level CanManageProducts policy) so that any
|
||
/// authenticated user — including those who can only create quotes or jobs — can load
|
||
/// thumbnails rendered in the item wizard.
|
||
/// </summary>
|
||
[Authorize]
|
||
[HttpGet]
|
||
public async Task<IActionResult> Image(int id, bool thumbnail = false)
|
||
{
|
||
try
|
||
{
|
||
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
|
||
if (item == null)
|
||
return NotFound();
|
||
|
||
var blobPath = thumbnail ? item.ThumbnailPath : item.ImagePath;
|
||
if (string.IsNullOrEmpty(blobPath))
|
||
return NotFound();
|
||
|
||
var (success, content, contentType, error) = await _catalogImageService.DownloadAsync(blobPath);
|
||
if (!success)
|
||
return NotFound();
|
||
|
||
return File(content, contentType);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error serving catalog image for item {ItemId}", id);
|
||
return NotFound();
|
||
}
|
||
}
|
||
|
||
/// <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));
|
||
}
|
||
}
|
||
|
||
// ── AI Price Check ────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Displays the most recent AI price-check report for this company, or an empty state
|
||
/// if the check has never been run. The report is stored as JSON in the database so
|
||
/// users can review it later without re-running the AI call.
|
||
/// </summary>
|
||
public async Task<IActionResult> AiPriceCheck()
|
||
{
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
if (currentUser == null) return Forbid();
|
||
|
||
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
|
||
r => r.CompanyId == currentUser.CompanyId);
|
||
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
|
||
|
||
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
|
||
ViewBag.ActiveItemCount = pricedItems.Count();
|
||
|
||
CatalogPriceCheckReportDto? dto = null;
|
||
if (report != null)
|
||
{
|
||
List<CatalogItemPriceVerdict> results;
|
||
try
|
||
{
|
||
results = JsonSerializer.Deserialize<List<CatalogItemPriceVerdict>>(
|
||
report.ResultsJson,
|
||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to deserialize stored price check report {ReportId}", report.Id);
|
||
TempData["Warning"] = "The previous report could not be loaded. Please re-run the price check.";
|
||
results = new();
|
||
}
|
||
|
||
dto = new CatalogPriceCheckReportDto
|
||
{
|
||
Id = report.Id,
|
||
RunAt = report.RunAt,
|
||
ItemsChecked = report.ItemsChecked,
|
||
Results = results,
|
||
OperatingCostsSummary = report.OperatingCostsSummary,
|
||
BelowCostCount = results.Count(r => r.Verdict == "below-cost"),
|
||
LowMarginCount = results.Count(r => r.Verdict == "low"),
|
||
HighPriceCount = results.Count(r => r.Verdict == "high"),
|
||
OkCount = results.Count(r => r.Verdict == "ok")
|
||
};
|
||
}
|
||
|
||
return View(dto);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs the AI price check against all active catalog items using the company's current
|
||
/// operating costs. Batches items in groups of 25 to stay within model context limits.
|
||
/// Overwrites any existing report for this company rather than accumulating a history,
|
||
/// since operating costs change and old reports become misleading.
|
||
/// </summary>
|
||
[HttpPost]
|
||
[ValidateAntiForgeryToken]
|
||
public async Task<IActionResult> RunAiPriceCheck()
|
||
{
|
||
var currentUser = await _userManager.GetUserAsync(User);
|
||
if (currentUser == null) return Forbid();
|
||
|
||
try
|
||
{
|
||
// Load active catalog items with a real price — skip $0 items (placeholders,
|
||
// category headers, etc.) since there's no pricing to evaluate.
|
||
var items = (await _unitOfWork.CatalogItems.FindAsync(
|
||
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
|
||
|
||
if (items.Count == 0)
|
||
{
|
||
TempData["Warning"] = "No priced catalog items to analyze. Add prices to your catalog items first.";
|
||
return RedirectToAction(nameof(AiPriceCheck));
|
||
}
|
||
|
||
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
||
// The full path gives Claude the coating-type context it needs — an item in
|
||
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
|
||
.ToDictionary(c => c.Id);
|
||
|
||
// Load company operating costs
|
||
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(
|
||
c => c.CompanyId == currentUser.CompanyId)).FirstOrDefault();
|
||
|
||
var costSummary = BuildCostSummary(costs);
|
||
|
||
// Map to service DTOs
|
||
var itemDtos = items.Select(i => new CatalogItemForPriceCheck
|
||
{
|
||
Id = i.Id,
|
||
Name = i.Name,
|
||
Description = i.Description,
|
||
CategoryName = BuildCategoryPath(i.CategoryId, allCategories),
|
||
CurrentPrice = i.DefaultPrice,
|
||
ApproximateAreaSqFt = i.ApproximateArea,
|
||
EstimatedMinutes = i.DefaultEstimatedMinutes,
|
||
RequiresSandblasting = i.DefaultRequiresSandblasting,
|
||
RequiresMasking = i.DefaultRequiresMasking
|
||
}).ToList();
|
||
|
||
// Run AI analysis
|
||
var verdicts = await _priceCheckService.AnalyzeAsync(itemDtos, costSummary);
|
||
|
||
// Soft-delete any previous report for this company
|
||
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
|
||
r => r.CompanyId == currentUser.CompanyId);
|
||
foreach (var old in existing)
|
||
await _unitOfWork.CatalogPriceCheckReports.SoftDeleteAsync(old.Id);
|
||
|
||
// Save new report
|
||
var report = new CatalogPriceCheckReport
|
||
{
|
||
CompanyId = currentUser.CompanyId,
|
||
RunAt = DateTime.UtcNow,
|
||
ItemsChecked = items.Count,
|
||
ResultsJson = JsonSerializer.Serialize(verdicts),
|
||
OperatingCostsSummary = BuildCostSummaryText(costSummary)
|
||
};
|
||
await _unitOfWork.CatalogPriceCheckReports.AddAsync(report);
|
||
await _unitOfWork.CompleteAsync();
|
||
|
||
TempData["Success"] = $"AI price check complete — {items.Count} items analyzed.";
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
TempData["Error"] = "The AI analysis timed out. Try again or reduce your catalog size.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "AI catalog price check failed");
|
||
TempData["Error"] = "An error occurred during the AI price check. Please try again.";
|
||
}
|
||
|
||
return RedirectToAction(nameof(AiPriceCheck));
|
||
}
|
||
|
||
private static ShopOperatingCostSummary BuildCostSummary(CompanyOperatingCosts? costs)
|
||
{
|
||
if (costs == null)
|
||
return new ShopOperatingCostSummary();
|
||
|
||
return new ShopOperatingCostSummary
|
||
{
|
||
LaborRatePerHour = costs.StandardLaborRate,
|
||
OvenCostPerHour = costs.OvenOperatingCostPerHour,
|
||
SandblasterCostPerHour = costs.SandblasterCostPerHour,
|
||
CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour,
|
||
PowderCostPerSqFt = costs.PowderCoatingCostPerSqFt,
|
||
ShopSuppliesRatePercent = costs.ShopSuppliesRate,
|
||
MarkupOrMarginPercent = costs.PricingMode == PricingMode.MarginOnTotalCost
|
||
? costs.TargetMarginPercent
|
||
: costs.GeneralMarkupPercentage,
|
||
PricingMode = costs.PricingMode == PricingMode.MarginOnTotalCost ? "margin" : "markup",
|
||
ShopMinimumCharge = costs.ShopMinimumCharge,
|
||
AiContextProfile = costs.AiContextProfile
|
||
};
|
||
}
|
||
|
||
private static string BuildCostSummaryText(ShopOperatingCostSummary c) =>
|
||
$"Labor ${c.LaborRatePerHour:F2}/hr | Oven ${c.OvenCostPerHour:F2}/hr | " +
|
||
$"Blaster ${c.SandblasterCostPerHour:F2}/hr | Booth ${c.CoatingBoothCostPerHour:F2}/hr | " +
|
||
$"Powder ${c.PowderCostPerSqFt:F2}/sqft | " +
|
||
$"{(c.PricingMode == "margin" ? "Margin" : "Markup")} {c.MarkupOrMarginPercent:F1}%";
|
||
|
||
/// <summary>
|
||
/// Walks up the category parent chain to produce a full path like "Cerakote > Firearms",
|
||
/// giving Claude the coating-type context it needs for accurate pricing analysis.
|
||
/// </summary>
|
||
private static string BuildCategoryPath(int? categoryId, Dictionary<int, CatalogCategory> all)
|
||
{
|
||
if (categoryId == null) return "Uncategorized";
|
||
var parts = new List<string>();
|
||
var current = all.GetValueOrDefault(categoryId.Value);
|
||
while (current != null)
|
||
{
|
||
parts.Insert(0, current.Name);
|
||
current = current.ParentCategoryId.HasValue
|
||
? all.GetValueOrDefault(current.ParentCategoryId.Value)
|
||
: null;
|
||
}
|
||
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
|
||
}
|
||
}
|
||
|
||
// 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; }
|
||
}
|
||
}
|