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
@@ -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>
}