Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CatalogItemsController.cs
T
spouliot 560a2c76b8 Add full category path to AI price check for coating-type context
- 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>
2026-04-25 20:35:41 -04:00

1123 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>&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>
/// 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; }
}
}