Fix company logo missing from PDFs and add AI photo save logging
When a tenant uploads a logo it is stored in Azure Blob Storage and LogoData (the legacy DB byte[]) is cleared. All PDF controllers were still reading the now-null LogoData, so logos never appeared on any PDF after upload. Fixed by injecting ICompanyLogoService into all six affected controllers (Quotes, Invoices, Deposits, GiftCertificates, PurchaseOrders, CatalogItems) and loading the blob-stored logo first before falling back to the legacy DB field. Also added structured logging to the AI photo promotion path in QuotesController Create/Edit POST so upload failures are visible in production logs instead of silently swallowed. Added onclick safety net to the Create and Edit quote submit buttons so dynamically-injected hidden fields (AiPhotoTempIds) are written before iOS Safari collects the form data on submit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
private readonly ICatalogImageService _catalogImageService;
|
private readonly ICatalogImageService _catalogImageService;
|
||||||
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
||||||
private readonly IPlatformSettingsService _platformSettings;
|
private readonly IPlatformSettingsService _platformSettings;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public CatalogItemsController(
|
public CatalogItemsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -52,7 +53,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
ISubscriptionService subscriptionService,
|
ISubscriptionService subscriptionService,
|
||||||
ICatalogImageService catalogImageService,
|
ICatalogImageService catalogImageService,
|
||||||
IAiCatalogPriceCheckService priceCheckService,
|
IAiCatalogPriceCheckService priceCheckService,
|
||||||
IPlatformSettingsService platformSettings)
|
IPlatformSettingsService platformSettings,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -65,6 +67,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
_catalogImageService = catalogImageService;
|
_catalogImageService = catalogImageService;
|
||||||
_priceCheckService = priceCheckService;
|
_priceCheckService = priceCheckService;
|
||||||
_platformSettings = platformSettings;
|
_platformSettings = platformSettings;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -906,11 +909,12 @@ namespace PowderCoating.Web.Controllers
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
|
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
|
||||||
itemsByCategory,
|
itemsByCategory,
|
||||||
company.CompanyName,
|
company.CompanyName,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType
|
logoContentType
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return PDF file
|
// Return PDF file
|
||||||
@@ -1146,6 +1150,17 @@ namespace PowderCoating.Web.Controllers
|
|||||||
}
|
}
|
||||||
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
|
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
|
{
|
||||||
|
if (company == null) return (null, null);
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper class for hierarchical display
|
// Helper class for hierarchical display
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.DTOs.Company;
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -20,15 +21,18 @@ public class DepositsController : Controller
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<DepositsController> _logger;
|
private readonly ILogger<DepositsController> _logger;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public DepositsController(
|
public DepositsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<DepositsController> logger)
|
ILogger<DepositsController> logger,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -191,7 +195,8 @@ public class DepositsController : Controller
|
|||||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
};
|
};
|
||||||
|
|
||||||
var pdfBytes = GenerateReceiptPdf(deposit, company?.LogoData, company?.LogoContentType, companyInfo, prefs?.InAccentColor);
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
|
var pdfBytes = GenerateReceiptPdf(deposit, logoData, logoContentType, companyInfo, prefs?.InAccentColor);
|
||||||
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
|
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
|
||||||
return File(pdfBytes, "application/pdf");
|
return File(pdfBytes, "application/pdf");
|
||||||
}
|
}
|
||||||
@@ -413,4 +418,15 @@ public class DepositsController : Controller
|
|||||||
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
||||||
return hex.StartsWith("#") ? hex : fallback;
|
return hex.StartsWith("#") ? hex : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
|
{
|
||||||
|
if (company == null) return (null, null);
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,22 @@ public class GiftCertificatesController : Controller
|
|||||||
private readonly ILogger<GiftCertificatesController> _logger;
|
private readonly ILogger<GiftCertificatesController> _logger;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public GiftCertificatesController(
|
public GiftCertificatesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
ILogger<GiftCertificatesController> logger,
|
ILogger<GiftCertificatesController> logger,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPdfService pdfService)
|
IPdfService pdfService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -340,7 +343,8 @@ public class GiftCertificatesController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo);
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
|
var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, logoData, logoContentType, companyInfo);
|
||||||
return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf");
|
return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -390,4 +394,15 @@ public class GiftCertificatesController : Controller
|
|||||||
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
|
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
|
||||||
ViewBag.Customers = list;
|
ViewBag.Customers = list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
|
{
|
||||||
|
if (company == null) return (null, null);
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class InvoicesController : Controller
|
|||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly INotificationService _notificationService;
|
private readonly INotificationService _notificationService;
|
||||||
private readonly IAccountBalanceService _accountBalanceService;
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public InvoicesController(
|
public InvoicesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -36,7 +37,8 @@ public class InvoicesController : Controller
|
|||||||
IPdfService pdfService,
|
IPdfService pdfService,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IAccountBalanceService accountBalanceService)
|
IAccountBalanceService accountBalanceService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -46,6 +48,7 @@ public class InvoicesController : Controller
|
|||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_notificationService = notificationService;
|
_notificationService = notificationService;
|
||||||
_accountBalanceService = accountBalanceService;
|
_accountBalanceService = accountBalanceService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1624,8 +1627,9 @@ public class InvoicesController : Controller
|
|||||||
DefaultTerms = prefs?.InDefaultTerms
|
DefaultTerms = prefs?.InDefaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var dto = await BuildInvoiceDtoAsync(invoice);
|
var dto = await BuildInvoiceDtoAsync(invoice);
|
||||||
return await _pdfService.GenerateInvoicePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo, template);
|
return await _pdfService.GenerateInvoicePdfAsync(dto, logoData, logoContentType, companyInfo, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -2444,4 +2448,19 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns logo bytes and content type for PDF generation.
|
||||||
|
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
|
{
|
||||||
|
if (company == null) return (null, null);
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,22 @@ public class PurchaseOrdersController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<PurchaseOrdersController> _logger;
|
private readonly ILogger<PurchaseOrdersController> _logger;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public PurchaseOrdersController(
|
public PurchaseOrdersController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<PurchaseOrdersController> logger,
|
ILogger<PurchaseOrdersController> logger,
|
||||||
IPdfService pdfService)
|
IPdfService pdfService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -684,8 +687,9 @@ public class PurchaseOrdersController : Controller
|
|||||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
|
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
|
||||||
dto, company?.LogoData, company?.LogoContentType, companyInfo);
|
dto, logoData, logoContentType, companyInfo);
|
||||||
|
|
||||||
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
|
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
|
||||||
}
|
}
|
||||||
@@ -847,4 +851,15 @@ public class PurchaseOrdersController : Controller
|
|||||||
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
||||||
ViewBag.VendorList = vendors;
|
ViewBag.VendorList = vendors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
|
{
|
||||||
|
if (company == null) return (null, null);
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class QuotesController : Controller
|
|||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
private readonly IJobPhotoService _jobPhotoService;
|
private readonly IJobPhotoService _jobPhotoService;
|
||||||
private readonly IAiUsageLogger _usageLogger;
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public QuotesController(
|
public QuotesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -59,7 +60,8 @@ public class QuotesController : Controller
|
|||||||
IAiQuoteService aiService,
|
IAiQuoteService aiService,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env,
|
||||||
IJobPhotoService jobPhotoService,
|
IJobPhotoService jobPhotoService,
|
||||||
IAiUsageLogger usageLogger)
|
IAiUsageLogger usageLogger,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -79,6 +81,7 @@ public class QuotesController : Controller
|
|||||||
_env = env;
|
_env = env;
|
||||||
_jobPhotoService = jobPhotoService;
|
_jobPhotoService = jobPhotoService;
|
||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -604,10 +607,11 @@ public class QuotesController : Controller
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
|
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
|
||||||
quoteDto,
|
quoteDto,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType,
|
logoContentType,
|
||||||
companyInfo,
|
companyInfo,
|
||||||
template: template
|
template: template
|
||||||
);
|
);
|
||||||
@@ -1037,13 +1041,22 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Promote AI temp photos to permanent storage and create QuotePhoto records
|
// Promote AI temp photos to permanent storage and create QuotePhoto records
|
||||||
|
_logger.LogInformation("CREATE AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
|
||||||
|
dto.AiPhotoTempIds?.Count ?? 0,
|
||||||
|
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
|
||||||
if (dto.AiPhotoTempIds?.Count > 0)
|
if (dto.AiPhotoTempIds?.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
|
if (!Guid.TryParse(rawTempId, out var tempGuid))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("CREATE AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var tempId = tempGuid.ToString("N");
|
var tempId = tempGuid.ToString("N");
|
||||||
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
_logger.LogInformation("CREATE AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
|
||||||
|
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||||
|
_logger.LogInformation("CREATE AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
|
||||||
if (promoted)
|
if (promoted)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||||
@@ -1895,13 +1908,22 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Promote any new AI temp photos and create QuotePhoto records
|
// Promote any new AI temp photos and create QuotePhoto records
|
||||||
|
_logger.LogInformation("EDIT AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
|
||||||
|
dto.AiPhotoTempIds?.Count ?? 0,
|
||||||
|
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
|
||||||
if (dto.AiPhotoTempIds?.Count > 0)
|
if (dto.AiPhotoTempIds?.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
|
if (!Guid.TryParse(rawTempId, out var tempGuid))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("EDIT AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var tempId = tempGuid.ToString("N");
|
var tempId = tempGuid.ToString("N");
|
||||||
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
_logger.LogInformation("EDIT AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
|
||||||
|
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||||
|
_logger.LogInformation("EDIT AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
|
||||||
if (promoted)
|
if (promoted)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||||
@@ -2826,10 +2848,11 @@ public class QuotesController : Controller
|
|||||||
DefaultTerms = prefs?.QtDefaultTerms
|
DefaultTerms = prefs?.QtDefaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
return await _pdfService.GenerateQuotePdfAsync(
|
return await _pdfService.GenerateQuotePdfAsync(
|
||||||
quoteDto,
|
quoteDto,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType,
|
logoContentType,
|
||||||
companyInfo,
|
companyInfo,
|
||||||
template: template);
|
template: template);
|
||||||
}
|
}
|
||||||
@@ -3908,6 +3931,30 @@ public class QuotesController : Controller
|
|||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates the caption of a non-AI quote photo.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> UpdateQuotePhoto(int id, string? caption)
|
||||||
|
{
|
||||||
|
var user = await _userManager.GetUserAsync(User);
|
||||||
|
if (user == null) return Json(new { success = false, error = "Not authenticated." });
|
||||||
|
|
||||||
|
var photo = await _unitOfWork.QuotePhotos.GetByIdAsync(id);
|
||||||
|
if (photo == null || photo.CompanyId != user.CompanyId)
|
||||||
|
return Json(new { success = false, error = "Photo not found." });
|
||||||
|
|
||||||
|
if (photo.IsAiAnalysisPhoto)
|
||||||
|
return Json(new { success = false, error = "AI analysis photos cannot be edited." });
|
||||||
|
|
||||||
|
photo.Caption = string.IsNullOrWhiteSpace(caption) ? null : caption.Trim();
|
||||||
|
photo.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _unitOfWork.QuotePhotos.UpdateAsync(photo);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||||
{
|
{
|
||||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
@@ -3936,6 +3983,21 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns logo bytes and content type for PDF generation.
|
||||||
|
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData)
|
||||||
|
/// so that companies that uploaded a new logo after initial setup see it in PDFs.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company company)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||||
|
{
|
||||||
|
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||||
|
if (ok) return (content, contentType);
|
||||||
|
}
|
||||||
|
return (company.LogoData, company.LogoContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request model for AJAX pricing calculation
|
// Request model for AJAX pricing calculation
|
||||||
|
|||||||
@@ -398,7 +398,7 @@
|
|||||||
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary btn-lg">
|
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary btn-lg">
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
</a>
|
</a>
|
||||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn" onclick="if(typeof writeHiddenFields==='function')writeHiddenFields()">
|
||||||
<i class="bi bi-check-circle me-1"></i>Create Quote
|
<i class="bi bi-check-circle me-1"></i>Create Quote
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,6 +363,10 @@
|
|||||||
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
|
||||||
|
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Edit caption">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<a href="@Url.Action("Photo", "Quotes", new { id = photo.Id })" target="_blank" title="View full size">
|
<a href="@Url.Action("Photo", "Quotes", new { id = photo.Id })" target="_blank" title="View full size">
|
||||||
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
||||||
@@ -372,6 +376,17 @@
|
|||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<p class="card-text small text-muted text-truncate mb-0" title="@photo.FileName">@photo.FileName</p>
|
<p class="card-text small text-muted text-truncate mb-0" title="@photo.FileName">@photo.FileName</p>
|
||||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">@photo.CreatedAt.ToString("MMM d, yyyy")</p>
|
<p class="card-text text-muted mb-0" style="font-size:.7rem;">@photo.CreatedAt.ToString("MMM d, yyyy")</p>
|
||||||
|
@if (!photo.IsAiAnalysisPhoto)
|
||||||
|
{
|
||||||
|
<p class="card-text small fst-italic text-truncate mb-0 caption-display" title="@photo.Caption">@photo.Caption</p>
|
||||||
|
<div class="caption-edit-form d-none mt-1" data-photo-id="@photo.Id">
|
||||||
|
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption...">@photo.Caption</textarea>
|
||||||
|
<div class="d-flex gap-1 mt-1">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,7 +413,7 @@
|
|||||||
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
</a>
|
</a>
|
||||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn" onclick="if(typeof writeHiddenFields==='function')writeHiddenFields()">
|
||||||
<i class="bi bi-check-circle me-1"></i>Update Quote
|
<i class="bi bi-check-circle me-1"></i>Update Quote
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,6 +723,7 @@
|
|||||||
const quoteId = @Model.Id;
|
const quoteId = @Model.Id;
|
||||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||||
|
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
|
||||||
const fileInput = document.getElementById('editPhotoFileInput');
|
const fileInput = document.getElementById('editPhotoFileInput');
|
||||||
@@ -744,12 +760,24 @@
|
|||||||
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
|
||||||
|
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Edit caption">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
<a href="${data.url}" target="_blank" title="View full size">
|
<a href="${data.url}" target="_blank" title="View full size">
|
||||||
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
|
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
|
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
|
||||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">Just now</p>
|
<p class="card-text text-muted mb-0" style="font-size:.7rem;">Just now</p>
|
||||||
|
<p class="card-text small fst-italic text-truncate mb-0 caption-display"></p>
|
||||||
|
<div class="caption-edit-form d-none mt-1" data-photo-id="${data.id}">
|
||||||
|
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption..."></textarea>
|
||||||
|
<div class="d-flex gap-1 mt-1">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
grid.appendChild(col);
|
grid.appendChild(col);
|
||||||
@@ -771,6 +799,45 @@
|
|||||||
updateCount(-1);
|
updateCount(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show inline caption editor
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.edit-caption-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const card = btn.closest('.photo-item');
|
||||||
|
card.querySelector('.caption-display')?.classList.add('d-none');
|
||||||
|
card.querySelector('.caption-edit-form')?.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel inline caption edit
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.cancel-caption-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const card = btn.closest('.photo-item');
|
||||||
|
card.querySelector('.caption-edit-form')?.classList.add('d-none');
|
||||||
|
card.querySelector('.caption-display')?.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save caption
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.save-caption-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const card = btn.closest('.photo-item');
|
||||||
|
const editForm = card.querySelector('.caption-edit-form');
|
||||||
|
const photoId = editForm?.dataset.photoId;
|
||||||
|
const caption = card.querySelector('.caption-textarea')?.value.trim() ?? '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', photoId);
|
||||||
|
fd.append('caption', caption);
|
||||||
|
fd.append('__RequestVerificationToken', token);
|
||||||
|
const resp = await fetch(updateUrl, { method: 'POST', body: fd });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) { alert(data.error || 'Update failed.'); return; }
|
||||||
|
const displayEl = card.querySelector('.caption-display');
|
||||||
|
if (displayEl) { displayEl.textContent = caption; displayEl.title = caption; }
|
||||||
|
editForm?.classList.add('d-none');
|
||||||
|
card.querySelector('.caption-display')?.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
function updateCount(delta) {
|
function updateCount(delta) {
|
||||||
const badge = document.getElementById('photoCount');
|
const badge = document.getElementById('photoCount');
|
||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||||||
|
|||||||
Reference in New Issue
Block a user