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
@@ -3242,7 +3242,8 @@ public class JobsController : Controller
categoryName = i.Category.Name,
price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -2686,7 +2686,8 @@ public class QuotesController : Controller
categoryName = i.Category.Name,
price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -916,9 +916,12 @@ public static class HelpKnowledgeBase
**How to add a catalog item:**
1. Go to [Catalog Items](/CatalogItems) "New Item"
2. Enter name, category, and the all-in price (including your labor and margin nothing will be added on top)
3. Save
3. Optionally upload an image in the Item Image section (jpg/jpeg/png/gif/webp, max 10 MB a 200×200 thumbnail is generated automatically)
4. Save
Catalog items can be selected in the quote/job wizard as an alternative to the full calculated or custom item workflow.
**Item images:** Each catalog item supports one optional image. Upload or replace it on the item's Edit page. When no image is set, a gray placeholder icon appears instead. Images appear as thumbnails in the catalog list and in the quote/job item wizard. Hovering over a thumbnail in the wizard shows a larger preview near the cursor so staff can quickly confirm the right part.
Catalog items can be selected in the quote/job wizard as an alternative to the full calculated or custom item workflow. The wizard's product list includes a search/filter box and shows thumbnails next to each item name for visual identification.
**Saving to catalog directly from the item wizard (Save-to-Catalog step):**
When a user completes a Calculated or AI Photo Quote item in the wizard, a final optional step appears: "Save to Product Catalog." This lets them create a reusable catalog entry from the item they just configured without navigating to the Catalog Items page separately.
+1
View File
@@ -193,6 +193,7 @@ builder.Services.AddSingleton<IAzureBlobStorageService, AzureBlobStorageService>
builder.Services.AddScoped<IProfilePhotoService, ProfilePhotoService>();
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
builder.Services.AddScoped<ICatalogImageService, CatalogImageService>();
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
@@ -14,7 +14,7 @@
</h4>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
<form asp-action="Create" method="post" enctype="multipart/form-data">
<partial name="_ValidationSummary" />
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
@@ -159,6 +159,17 @@
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job (e.g. branded apparel, retail cleaning products).</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary">
@@ -349,5 +360,17 @@
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => { img.src = e.target.result; preview.classList.remove('d-none'); };
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('d-none');
}
}
</script>
}
@@ -134,8 +134,25 @@
</div>
</div>
<!-- Right Column: Actions -->
<!-- Right Column: Image + Actions -->
<div class="col-lg-4">
@if (!string.IsNullOrEmpty(Model.ImagePath))
{
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-image me-1"></i>Item Image</h6>
</div>
<div class="card-body p-2 text-center">
<a href="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = false })" target="_blank">
<img src="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = true })"
alt="@Model.Name"
style="max-width:100%;border-radius:6px;" />
</a>
<p class="text-muted small mt-1 mb-0">Click to view full size</p>
</div>
</div>
}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
@@ -14,7 +14,7 @@
</h4>
</div>
<div class="card-body">
<form asp-action="Edit" method="post">
<form asp-action="Edit" method="post" enctype="multipart/form-data">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
@@ -159,6 +159,40 @@
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job.</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
@if (ViewBag.HasImage == true)
{
<div class="mb-3 d-flex align-items-start gap-3">
<div>
<img id="imagePreviewImg"
src="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = true })"
alt="Current image"
style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;" />
</div>
<div>
<p class="mb-1 fw-semibold">Current Image</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="removeImage" id="removeImage" value="true" />
<label class="form-check-label text-danger" for="removeImage">Remove current image</label>
</div>
<label for="image" class="form-label text-muted small">Replace with a new image:</label>
<input type="file" class="form-control form-control-sm" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
</div>
</div>
}
else
{
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
}
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
@@ -345,5 +379,20 @@
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => {
img.src = e.target.result;
if (preview) preview.classList.remove('d-none');
};
reader.readAsDataURL(input.files[0]);
} else {
if (preview) preview.classList.add('d-none');
}
}
</script>
}
@@ -34,9 +34,21 @@
@foreach (var item in Model.Items)
{
<div class="item-row">
<div class="item-row-name">
<div class="item-row-name d-flex align-items-center gap-2">
@if (!string.IsNullOrEmpty(item.ThumbnailPath))
{
<img src="@Url.Action("Image", "CatalogItems", new { id = item.Id, thumbnail = true })"
alt="@item.Name"
style="width:40px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0;" />
}
else
{
<span style="width:40px;height:40px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-image catalog-text-muted"></i>
</span>
}
<a asp-action="Details" asp-route-id="@item.Id" class="catalog-item-link">
<i class="bi bi-box me-2 catalog-text-muted"></i>@item.Name
@item.Name
</a>
</div>
<div class="item-row-meta">
@@ -123,6 +123,23 @@
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Selecting a Product from Catalog</h3>
<p>
When you choose the <strong>Product from Catalog</strong> item type, the wizard shows a scrollable
list of all your active catalog items with a search box at the top. Start typing any part of the
item name, SKU, or category to filter the list instantly.
</p>
<p>
If an image has been uploaded for a catalog item, a small thumbnail appears to the left of its
name in the list. <strong>Hover over the thumbnail</strong> to see a larger preview near your
cursor — useful for quickly confirming you have the right part without opening the full item record.
</p>
<p>
Images are managed on the <a href="/CatalogItems">Catalog Items</a> page — open any item, click
<strong>Edit</strong>, and use the <strong>Item Image</strong> section to upload a photo
(jpg, jpeg, png, gif, or webp; max 10 MB). A 200&times;200 thumbnail is generated automatically.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Coatings and Prep Services</h3>
<p>
For Calculated and AI Photo items, after entering the surface area you proceed to the coatings
+2 -1
View File
@@ -75,7 +75,8 @@
"JobImages": "jobimages",
"Manuals": "manuals",
"CompanyLogos": "companylogos",
"ReceiptImages": "receiptimages"
"ReceiptImages": "receiptimages",
"CatalogImages": "catalogimages"
}
}
}
@@ -341,9 +341,19 @@ function renderStep2Html() {
}
function renderProductFields() {
const catalogItems = catalogData.map(c =>
`<div class="catalog-list-item px-3 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)">${escHtml(c.text)}</div>`
).join('');
ensureCatalogPreviewEl();
const catalogItems = catalogData.map(c => {
const thumbHtml = c.thumbnailPath
? `<img src="/CatalogItems/Image?id=${c.value}&thumbnail=true" alt=""
style="width:36px;height:36px;object-fit:cover;border-radius:4px;flex-shrink:0;cursor:zoom-in;"
onmouseenter="showCatalogPreview(event,'/CatalogItems/Image?id=${c.value}&thumbnail=true')"
onmousemove="moveCatalogPreview(event)"
onmouseleave="hideCatalogPreview()" />`
: `<span style="width:36px;height:36px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;"><i class='bi bi-image text-muted' style='font-size:.85rem;'></i></span>`;
// Inner div carries the flex layout — the outer catalog-list-item div must stay a plain block element
// so filterCatalog() can set el.style.display='none' without Bootstrap d-flex !important overriding it.
return `<div class="catalog-list-item px-2 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)"><div style="display:flex;align-items:center;gap:0.5rem;">${thumbHtml}<span>${escHtml(c.text)}</span></div></div>`;
}).join('');
return `
<div class="mb-3">
@@ -385,6 +395,49 @@ function pickCatalogItem(el) {
document.getElementById('err_catalogItemId')?.classList.add('d-none');
}
// ── Catalog thumbnail hover preview ──────────────────────────────────────────
function ensureCatalogPreviewEl() {
if (document.getElementById('catalogThumbPreview')) return;
const el = document.createElement('div');
el.id = 'catalogThumbPreview';
el.style.cssText = 'position:fixed;display:none;z-index:9999;pointer-events:none;' +
'border:1px solid #dee2e6;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.18);' +
'background:#fff;padding:4px;';
el.innerHTML = '<img id="catalogThumbPreviewImg" style="display:block;width:200px;height:200px;object-fit:contain;border-radius:4px;" />';
document.body.appendChild(el);
}
function showCatalogPreview(event, url) {
const preview = document.getElementById('catalogThumbPreview');
const img = document.getElementById('catalogThumbPreviewImg');
if (!preview || !img) return;
img.src = url;
_placeCatalogPreview(event, preview);
preview.style.display = 'block';
}
function moveCatalogPreview(event) {
const preview = document.getElementById('catalogThumbPreview');
if (preview && preview.style.display !== 'none') _placeCatalogPreview(event, preview);
}
function hideCatalogPreview() {
const preview = document.getElementById('catalogThumbPreview');
if (preview) preview.style.display = 'none';
}
function _placeCatalogPreview(event, preview) {
const pad = 16, pw = 216, ph = 216;
let x = event.clientX + pad;
let y = event.clientY - ph / 2;
if (x + pw > window.innerWidth) x = event.clientX - pw - pad;
if (y < 8) y = 8;
if (y + ph > window.innerHeight) y = window.innerHeight - ph - 8;
preview.style.left = x + 'px';
preview.style.top = y + 'px';
}
function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft';
return `