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