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 { /// /// 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 IsMerchandise 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 /// (AllowAccounting middleware flag), so the dropdowns degrade gracefully to empty lists. /// [Authorize(Policy = AppConstants.Policies.CanManageProducts)] public class CatalogItemsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly ILogger _logger; private readonly IPdfService _pdfService; private readonly UserManager _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 logger, IPdfService pdfService, UserManager 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; } /// /// 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. /// public async Task 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(); 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(); 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()); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// public async Task 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)); } } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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(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); } } /// /// 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. /// public async Task 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(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)); } } /// /// Applies edits to an existing catalog item. Uses AutoMapper's map-onto-existing-entity overload /// (_mapper.Map(dto, item)) 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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); } } /// /// 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. /// public async Task 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(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)); } } /// /// 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. /// [HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task 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)); } } /// /// AJAX endpoint that returns active items belonging to a single category, ordered by /// DisplayOrder 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. /// [HttpGet] public async Task 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." }); } } /// /// AJAX endpoint that returns all active merchandise items grouped by category for the invoice /// merchandise picker. Only items with IsMerchandise = true 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 /// RevenueAccountId so the invoice create page can pre-populate the GL mapping without /// a separate lookup. /// [HttpGet] public async Task 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." }); } } /// /// 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. /// [HttpGet] public async Task SearchItems(string searchTerm) { try { if (string.IsNullOrWhiteSpace(searchTerm)) { return Json(new List()); } 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." }); } } /// /// 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 because the quote wizard consumes JSON, not a view, and /// needs a slightly different field set (camelCase, null-coalesced primitives). /// [HttpGet] public async Task 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." }); } } /// /// Populates ViewBag.RevenueAccounts and ViewBag.CogsAccounts for the Create/Edit /// forms. Returns empty lists when the company's subscription does not include accounting /// (AllowAccounting 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. /// private async Task PopulateAccountDropdowns() { var allowAccounting = HttpContext.Items["AllowAccounting"] as bool? ?? false; if (!allowAccounting) { ViewBag.RevenueAccounts = new List(); ViewBag.CogsAccounts = new List(); 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; } /// /// Populates ViewBag.Categories 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 <select> element. A proper hierarchical /// select control would require JavaScript; this simpler approach works without any JS dependency. /// private async Task PopulateCategoryDropdown() { var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList(); // Build hierarchical list (parents before children) var hierarchicalList = new List(); BuildHierarchicalCategoryList(categories, null, hierarchicalList); var categoryList = hierarchicalList .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = GetCategoryDisplayName(c, categories) }) .ToList(); ViewBag.Categories = categoryList; } /// /// Recursively walks the category tree depth-first, appending each node to /// before its children. This pre-order traversal ensures the flat list respects the parent→child /// visual ordering required by the indented dropdown approach. /// private void BuildHierarchicalCategoryList(List allCategories, int? parentId, List 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); } } /// /// Returns an indented display name for a category using non-breaking spaces and an em dash /// (\u00A0, \u2014) so hierarchy is visible inside a plain HTML select element. /// Regular spaces are deliberately avoided because browsers collapse them in option text. /// private string GetCategoryDisplayName(CatalogCategory category, List 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}"; } /// /// 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. /// private int GetCategoryDepth(CatalogCategory category, List allCategories) { int depth = 0; var current = category; var visited = new HashSet { 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; } /// /// Recursively builds the nested 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. /// private List BuildCategoryHierarchy( List allCategories, int? parentId, List filteredItems, string? searchTerm, int? categoryFilter) { var result = new List(); 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(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; } /// /// 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. /// [Authorize] [HttpGet] public async Task 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(); } } /// /// 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. /// public async Task 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 ──────────────────────────────────────────────────── /// /// 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. /// public async Task 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 results; try { results = JsonSerializer.Deserialize>( 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); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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}%"; /// /// 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. /// private static string BuildCategoryPath(int? categoryId, Dictionary all) { if (categoryId == null) return "Uncategorized"; var parts = new List(); 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 Items { get; set; } = new(); public List SubCategories { get; set; } = new(); public int TotalItems { get; set; } } }