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
@@ -15,4 +15,5 @@ public class StorageContainers
public string ReceiptImages { get; set; } = "receiptimages"; public string ReceiptImages { get; set; } = "receiptimages";
public string QuoteImages { get; set; } = "quoteimages"; public string QuoteImages { get; set; } = "quoteimages";
public string BugReportMedia { get; set; } = "bugreportmedia"; public string BugReportMedia { get; set; } = "bugreportmedia";
public string CatalogImages { get; set; } = "catalogimages";
} }
@@ -29,6 +29,9 @@ namespace PowderCoating.Application.DTOs.Catalog
[Display(Name = "COGS Account")] [Display(Name = "COGS Account")]
public int? CogsAccountId { get; set; } public int? CogsAccountId { get; set; }
public string? CogsAccountName { get; set; } public string? CogsAccountName { get; set; }
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
} }
/// <summary> /// <summary>
@@ -43,6 +46,7 @@ namespace PowderCoating.Application.DTOs.Catalog
public string CategoryName { get; set; } = string.Empty; public string CategoryName { get; set; } = string.Empty;
public decimal DefaultPrice { get; set; } public decimal DefaultPrice { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public string? ThumbnailPath { get; set; }
} }
/// <summary> /// <summary>
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Handles upload, thumbnail generation, and deletion of catalog item images stored in Azure Blob Storage.
/// All blobs are scoped under {companyId}/catalog/{itemId}/ so tenants never share a path.
/// </summary>
public interface ICatalogImageService
{
/// <summary>
/// Uploads the image, generates a 200×200 JPEG thumbnail, stores both blobs, and returns their paths.
/// On success the caller should persist the returned paths to <c>CatalogItem.ImagePath</c> and
/// <c>CatalogItem.ThumbnailPath</c>. Any previously stored blobs for the same item are deleted first.
/// </summary>
Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath);
/// <summary>
/// Downloads a catalog image blob and returns its raw bytes and content-type for streaming to the browser.
/// </summary>
Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath);
/// <summary>
/// Deletes both the full-size image and thumbnail blobs. Safe to call with null paths.
/// </summary>
Task DeleteAsync(string? imagePath, string? thumbnailPath);
}
@@ -92,7 +92,10 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore()) .ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore()) .ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore()) .ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore()); .ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
// Image paths are set by CatalogImageService after the entity is saved, not from the DTO.
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// UpdateCatalogItemDto -> CatalogItem // UpdateCatalogItemDto -> CatalogItem
CreateMap<UpdateCatalogItemDto, CatalogItem>() CreateMap<UpdateCatalogItemDto, CatalogItem>()
@@ -104,7 +107,9 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore()) .ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore()) .ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore()) .ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore()); .ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// CatalogItem -> UpdateCatalogItemDto (reverse mapping for Edit) // CatalogItem -> UpdateCatalogItemDto (reverse mapping for Edit)
CreateMap<CatalogItem, UpdateCatalogItemDto>(); CreateMap<CatalogItem, UpdateCatalogItemDto>();
@@ -18,6 +18,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" /> <PackageReference Include="QuestPDF" Version="2024.12.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!-- Force newer versions of transitive packages with known CVEs --> <!-- Force newer versions of transitive packages with known CVEs -->
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" /> <PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.5" /> <PackageReference Include="System.Text.Json" Version="8.0.5" />
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a
/// 200×200 JPEG thumbnail. Both blobs are stored under <c>{companyId}/catalog/{itemId}/</c> so that
/// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically
/// (delete-then-upload) to avoid orphaned files accumulating over time.
/// </summary>
public class CatalogImageService : ICatalogImageService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<CatalogImageService> _logger;
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
private const int ThumbnailSize = 200;
public CatalogImageService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<CatalogImageService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG
/// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as
/// JPEG regardless of the source format for predictable browser rendering and smaller file sizes.
/// </summary>
public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N");
var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}";
var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg";
// Delete existing blobs before uploading replacements.
await DeleteAsync(existingImagePath, existingThumbnailPath);
// Upload original.
using var originalStream = file.OpenReadStream();
var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType);
if (!uploadResult.Success)
return (false, string.Empty, string.Empty, uploadResult.ErrorMessage);
// Generate and upload thumbnail.
using var thumbStream = await GenerateThumbnailAsync(file);
if (thumbStream == null)
{
// Thumbnail generation failed; clean up the original and bail out.
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, "Failed to generate thumbnail.");
}
var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg");
if (!thumbResult.Success)
{
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, thumbResult.ErrorMessage);
}
_logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath);
return (true, imagePath, thumbPath, string.Empty);
}
/// <inheritdoc/>
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath)
{
return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath);
}
/// <inheritdoc/>
public async Task DeleteAsync(string? imagePath, string? thumbnailPath)
{
var container = _settings.Containers.CatalogImages;
if (!string.IsNullOrEmpty(imagePath))
await _blobService.DeleteAsync(container, imagePath);
if (!string.IsNullOrEmpty(thumbnailPath))
await _blobService.DeleteAsync(container, thumbnailPath);
}
/// <summary>
/// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while
/// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the
/// caller can surface a clean error without propagating an ImageSharp exception.
/// </summary>
private async Task<MemoryStream?> GenerateThumbnailAsync(IFormFile file)
{
try
{
using var inputStream = file.OpenReadStream();
using var image = await Image.LoadAsync(inputStream);
image.Mutate(ctx => ctx.Resize(new ResizeOptions
{
Size = new Size(ThumbnailSize, ThumbnailSize),
Mode = ResizeMode.Max
}));
var ms = new MemoryStream();
await image.SaveAsync(ms, new JpegEncoder { Quality = 85 });
ms.Position = 0;
return ms;
}
catch (Exception ex)
{
_logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName);
return null;
}
}
}
@@ -97,5 +97,19 @@ namespace PowderCoating.Core.Entities
public virtual Account? RevenueAccount { get; set; } public virtual Account? RevenueAccount { get; set; }
public virtual Account? CogsAccount { get; set; } public virtual Account? CogsAccount { get; set; }
// ── Images ────────────────────────────────────────────────────────────
/// <summary>
/// Blob path of the full-size uploaded image, relative to the catalogimages container.
/// Null when no image has been uploaded.
/// </summary>
public string? ImagePath { get; set; }
/// <summary>
/// Blob path of the 200×200 thumbnail generated on upload.
/// Null when no image has been uploaded.
/// </summary>
public string? ThumbnailPath { get; set; }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCatalogItemImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImagePath",
table: "CatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ThumbnailPath",
table: "CatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImagePath",
table: "CatalogItems");
migrationBuilder.DropColumn(
name: "ThumbnailPath",
table: "CatalogItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164));
}
}
}
@@ -1416,6 +1416,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DisplayOrder") b.Property<int>("DisplayOrder")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("ImagePath")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InventoryItemId") b.Property<int?>("InventoryItemId")
.HasColumnType("int"); .HasColumnType("int");
@@ -1438,6 +1441,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("SKU") b.Property<string>("SKU")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ThumbnailPath")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -5776,7 +5782,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155), CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -5787,7 +5793,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162), CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -5798,7 +5804,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164), CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -21,6 +21,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" /> <PackageReference Include="Stripe.net" Version="50.4.1" />
<PackageReference Include="Twilio" Version="7.14.3" /> <PackageReference Include="Twilio" Version="7.14.3" />
</ItemGroup> </ItemGroup>
@@ -34,6 +34,7 @@ namespace PowderCoating.Web.Controllers
private readonly ITenantContext _tenantContext; private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService; private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService; private readonly ISubscriptionService _subscriptionService;
private readonly ICatalogImageService _catalogImageService;
public CatalogItemsController( public CatalogItemsController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -43,7 +44,8 @@ namespace PowderCoating.Web.Controllers
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
ITenantContext tenantContext, ITenantContext tenantContext,
IMeasurementConversionService measurementService, IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService) ISubscriptionService subscriptionService,
ICatalogImageService catalogImageService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -53,6 +55,7 @@ namespace PowderCoating.Web.Controllers
_tenantContext = tenantContext; _tenantContext = tenantContext;
_measurementService = measurementService; _measurementService = measurementService;
_subscriptionService = subscriptionService; _subscriptionService = subscriptionService;
_catalogImageService = catalogImageService;
} }
/// <summary> /// <summary>
@@ -215,7 +218,7 @@ namespace PowderCoating.Web.Controllers
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCatalogItemDto dto) public async Task<IActionResult> Create(CreateCatalogItemDto dto, IFormFile? image)
{ {
try try
{ {
@@ -241,6 +244,22 @@ namespace PowderCoating.Web.Controllers
await _unitOfWork.CatalogItems.AddAsync(item); await _unitOfWork.CatalogItems.AddAsync(item);
await _unitOfWork.CompleteAsync(); 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."; TempData["Success"] = $"Catalog item '{item.Name}' created successfully.";
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
@@ -257,7 +276,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown(); await PopulateCategoryDropdown();
await PopulateAccountDropdowns(); await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -288,6 +306,10 @@ namespace PowderCoating.Web.Controllers
_logger.LogDebug("Mapping item {ItemId} to DTO", id); _logger.LogDebug("Mapping item {ItemId} to DTO", id);
var dto = _mapper.Map<UpdateCatalogItemDto>(item); 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); _logger.LogDebug("Populating category dropdown for item {ItemId}", id);
await PopulateCategoryDropdown(); await PopulateCategoryDropdown();
await PopulateAccountDropdowns(); await PopulateAccountDropdowns();
@@ -317,7 +339,7 @@ namespace PowderCoating.Web.Controllers
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [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) if (id != dto.Id)
{ {
@@ -337,6 +359,29 @@ namespace PowderCoating.Web.Controllers
} }
_mapper.Map(dto, item); _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(); await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully."; TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
@@ -346,7 +391,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown(); await PopulateCategoryDropdown();
await PopulateAccountDropdowns(); await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -359,7 +403,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown(); await PopulateCategoryDropdown();
await PopulateAccountDropdowns(); await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -452,7 +495,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting, i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking, i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes, i.DefaultEstimatedMinutes,
i.ApproximateArea i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
}) })
.ToList(); .ToList();
@@ -541,7 +585,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting, i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking, i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes, i.DefaultEstimatedMinutes,
i.ApproximateArea i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
}) })
.ToList(); .ToList();
@@ -581,7 +626,8 @@ namespace PowderCoating.Web.Controllers
requiresMasking = item.DefaultRequiresMasking, requiresMasking = item.DefaultRequiresMasking,
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0, estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
approximateArea = item.ApproximateArea ?? 0, approximateArea = item.ApproximateArea ?? 0,
categoryName = item.Category.Name categoryName = item.Category.Name,
thumbnailPath = item.ThumbnailPath
}; };
return Json(itemData); return Json(itemData);
@@ -777,6 +823,39 @@ namespace PowderCoating.Web.Controllers
return result; 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> /// <summary>
/// Generates and streams a PDF of all active catalog items, grouped by category, including the /// 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 /// 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, categoryName = i.Category.Name,
price = i.DefaultPrice, price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m, approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0 defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList(); }).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step // Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -2686,7 +2686,8 @@ public class QuotesController : Controller
categoryName = i.Category.Name, categoryName = i.Category.Name,
price = i.DefaultPrice, price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m, approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0 defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList(); }).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step // Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -916,9 +916,12 @@ public static class HelpKnowledgeBase
**How to add a catalog item:** **How to add a catalog item:**
1. Go to [Catalog Items](/CatalogItems) "New 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) 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):** **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. 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<IProfilePhotoService, ProfilePhotoService>();
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>(); builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>(); builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
builder.Services.AddScoped<ICatalogImageService, CatalogImageService>();
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>(); builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>(); builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>(); builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
@@ -14,7 +14,7 @@
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<form asp-action="Create" method="post"> <form asp-action="Create" method="post" enctype="multipart/form-data">
<partial name="_ValidationSummary" /> <partial name="_ValidationSummary" />
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert"> <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 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> </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 --> <!-- Actions -->
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary"> <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> </script>
} }
@@ -134,8 +134,25 @@
</div> </div>
</div> </div>
<!-- Right Column: Actions --> <!-- Right Column: Image + Actions -->
<div class="col-lg-4"> <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">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Actions</h5> <h5 class="mb-0">Actions</h5>
@@ -14,7 +14,7 @@
</h4> </h4>
</div> </div>
<div class="card-body"> <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" /> <input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" /> <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 class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job.</div>
</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 --> <!-- Actions -->
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary"> <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> </script>
} }
@@ -34,9 +34,21 @@
@foreach (var item in Model.Items) @foreach (var item in Model.Items)
{ {
<div class="item-row"> <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"> <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> </a>
</div> </div>
<div class="item-row-meta"> <div class="item-row-meta">
@@ -123,6 +123,23 @@
</div> </div>
</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> <h3 class="h6 fw-semibold mt-3 mb-2">Coatings and Prep Services</h3>
<p> <p>
For Calculated and AI Photo items, after entering the surface area you proceed to the coatings 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", "JobImages": "jobimages",
"Manuals": "manuals", "Manuals": "manuals",
"CompanyLogos": "companylogos", "CompanyLogos": "companylogos",
"ReceiptImages": "receiptimages" "ReceiptImages": "receiptimages",
"CatalogImages": "catalogimages"
} }
} }
} }
@@ -341,9 +341,19 @@ function renderStep2Html() {
} }
function renderProductFields() { function renderProductFields() {
const catalogItems = catalogData.map(c => ensureCatalogPreviewEl();
`<div class="catalog-list-item px-3 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)">${escHtml(c.text)}</div>` const catalogItems = catalogData.map(c => {
).join(''); 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 ` return `
<div class="mb-3"> <div class="mb-3">
@@ -385,6 +395,49 @@ function pickCatalogItem(el) {
document.getElementById('err_catalogItemId')?.classList.add('d-none'); 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() { function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft'; const areaUnit = pageMeta.areaUnit || 'sq ft';
return ` return `