Add catalog item images with thumbnail preview in wizard

Each catalog item now supports one optional image (jpg/jpeg/png/gif/webp,
max 10 MB). Uploading generates a 200x200 JPEG thumbnail automatically via
SixLabors.ImageSharp. Images are stored in Azure Blob Storage under a new
catalogimages container, keyed by {companyId}/catalog/{itemId}/.

- CatalogItem entity: ImagePath + ThumbnailPath (nullable string fields)
- Migration: AddCatalogItemImages applied
- ICatalogImageService / CatalogImageService: upload, thumbnail generation,
  delete; old blobs replaced atomically on re-upload
- CatalogItemsController: Create/Edit accept optional IFormFile image;
  Image(id, thumbnail) action serves blobs with [Authorize] so wizard users
  can load thumbnails without CanManageProducts policy
- Catalog index (_CategoryNode): 40x40 thumbnail (or placeholder icon)
  left of each item name
- Details view: image card in right column with click-to-full-size link
- Create/Edit views: file picker with live preview; Edit shows current
  thumbnail with Remove checkbox
- Wizard (item-wizard.js): thumbnails in product list with hover preview
  that follows the cursor (showCatalogPreview / moveCatalogPreview);
  fixed Bootstrap d-flex !important bug that broke the filter box by
  moving flex layout to an inner wrapper div

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 09:33:59 -04:00
parent 3327c86909
commit 00bf8a4cd0
23 changed files with 9766 additions and 27 deletions
@@ -34,6 +34,7 @@ namespace PowderCoating.Web.Controllers
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
private readonly ICatalogImageService _catalogImageService;
public CatalogItemsController(
IUnitOfWork unitOfWork,
@@ -43,7 +44,8 @@ namespace PowderCoating.Web.Controllers
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService)
ISubscriptionService subscriptionService,
ICatalogImageService catalogImageService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -53,6 +55,7 @@ namespace PowderCoating.Web.Controllers
_tenantContext = tenantContext;
_measurementService = measurementService;
_subscriptionService = subscriptionService;
_catalogImageService = catalogImageService;
}
/// <summary>
@@ -215,7 +218,7 @@ namespace PowderCoating.Web.Controllers
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCatalogItemDto dto)
public async Task<IActionResult> Create(CreateCatalogItemDto dto, IFormFile? image)
{
try
{
@@ -241,6 +244,22 @@ namespace PowderCoating.Web.Controllers
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));
}
@@ -257,7 +276,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -288,6 +306,10 @@ namespace PowderCoating.Web.Controllers
_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();
@@ -317,7 +339,7 @@ namespace PowderCoating.Web.Controllers
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto)
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto, IFormFile? image, bool removeImage = false)
{
if (id != dto.Id)
{
@@ -337,6 +359,29 @@ namespace PowderCoating.Web.Controllers
}
_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.";
@@ -346,7 +391,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -359,7 +403,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -452,7 +495,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
})
.ToList();
@@ -541,7 +585,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
})
.ToList();
@@ -581,7 +626,8 @@ namespace PowderCoating.Web.Controllers
requiresMasking = item.DefaultRequiresMasking,
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
approximateArea = item.ApproximateArea ?? 0,
categoryName = item.Category.Name
categoryName = item.Category.Name,
thumbnailPath = item.ThumbnailPath
};
return Json(itemData);
@@ -777,6 +823,39 @@ namespace PowderCoating.Web.Controllers
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