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:
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9197
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.
|
||||||
|
|||||||
@@ -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×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
|
||||||
|
|||||||
@@ -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 `
|
||||||
|
|||||||
Reference in New Issue
Block a user