diff --git a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs index 2054ee9..190c64a 100644 --- a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs @@ -40,6 +40,7 @@ namespace PowderCoating.Web.Controllers private readonly ICatalogImageService _catalogImageService; private readonly IAiCatalogPriceCheckService _priceCheckService; private readonly IPlatformSettingsService _platformSettings; + private readonly ICompanyLogoService _logoService; public CatalogItemsController( IUnitOfWork unitOfWork, @@ -52,7 +53,8 @@ namespace PowderCoating.Web.Controllers ISubscriptionService subscriptionService, ICatalogImageService catalogImageService, IAiCatalogPriceCheckService priceCheckService, - IPlatformSettingsService platformSettings) + IPlatformSettingsService platformSettings, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; @@ -65,6 +67,7 @@ namespace PowderCoating.Web.Controllers _catalogImageService = catalogImageService; _priceCheckService = priceCheckService; _platformSettings = platformSettings; + _logoService = logoService; } /// @@ -906,11 +909,12 @@ namespace PowderCoating.Web.Controllers .ToList(); // Generate PDF + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GenerateCatalogPdfAsync( itemsByCategory, company.CompanyName, - company.LogoData, - company.LogoContentType + logoData, + logoContentType ); // Return PDF file @@ -1146,6 +1150,17 @@ namespace PowderCoating.Web.Controllers } 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 diff --git a/src/PowderCoating.Web/Controllers/DepositsController.cs b/src/PowderCoating.Web/Controllers/DepositsController.cs index 4d26ab0..b547beb 100644 --- a/src/PowderCoating.Web/Controllers/DepositsController.cs +++ b/src/PowderCoating.Web/Controllers/DepositsController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Company; +using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; @@ -20,15 +21,18 @@ public class DepositsController : Controller private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly ICompanyLogoService _logoService; public DepositsController( IUnitOfWork unitOfWork, UserManager userManager, - ILogger logger) + ILogger logger, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _userManager = userManager; _logger = logger; + _logoService = logoService; } // ----------------------------------------------------------------------- @@ -191,7 +195,8 @@ public class DepositsController : Controller 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\""; return File(pdfBytes, "application/pdf"); } @@ -413,4 +418,15 @@ public class DepositsController : Controller if (string.IsNullOrWhiteSpace(hex)) return 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); + } } diff --git a/src/PowderCoating.Web/Controllers/GiftCertificatesController.cs b/src/PowderCoating.Web/Controllers/GiftCertificatesController.cs index 613d97d..3e9e469 100644 --- a/src/PowderCoating.Web/Controllers/GiftCertificatesController.cs +++ b/src/PowderCoating.Web/Controllers/GiftCertificatesController.cs @@ -30,19 +30,22 @@ public class GiftCertificatesController : Controller private readonly ILogger _logger; private readonly UserManager _userManager; private readonly IPdfService _pdfService; + private readonly ICompanyLogoService _logoService; public GiftCertificatesController( IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, UserManager userManager, - IPdfService pdfService) + IPdfService pdfService, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; _userManager = userManager; _pdfService = pdfService; + _logoService = logoService; } /// @@ -340,7 +343,8 @@ public class GiftCertificatesController : Controller 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"); } catch (Exception ex) @@ -390,4 +394,15 @@ public class GiftCertificatesController : Controller list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" }); 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); + } } diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 120642e..9c06386 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -27,6 +27,7 @@ public class InvoicesController : Controller private readonly ITenantContext _tenantContext; private readonly INotificationService _notificationService; private readonly IAccountBalanceService _accountBalanceService; + private readonly ICompanyLogoService _logoService; public InvoicesController( IUnitOfWork unitOfWork, @@ -36,7 +37,8 @@ public class InvoicesController : Controller IPdfService pdfService, ITenantContext tenantContext, INotificationService notificationService, - IAccountBalanceService accountBalanceService) + IAccountBalanceService accountBalanceService, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; @@ -46,6 +48,7 @@ public class InvoicesController : Controller _tenantContext = tenantContext; _notificationService = notificationService; _accountBalanceService = accountBalanceService; + _logoService = logoService; } // ----------------------------------------------------------------------- @@ -1624,8 +1627,9 @@ public class InvoicesController : Controller DefaultTerms = prefs?.InDefaultTerms }; + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); 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; } + + /// + /// Returns logo bytes and content type for PDF generation. + /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData). + /// + 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); + } } diff --git a/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs index 9e74070..04a0730 100644 --- a/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs +++ b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs @@ -23,19 +23,22 @@ public class PurchaseOrdersController : Controller private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; + private readonly ICompanyLogoService _logoService; public PurchaseOrdersController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, ILogger logger, - IPdfService pdfService) + IPdfService pdfService, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; _pdfService = pdfService; + _logoService = logoService; } // ----------------------------------------------------------------------- @@ -684,8 +687,9 @@ public class PurchaseOrdersController : Controller PrimaryContactEmail = company?.PrimaryContactEmail }; + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync( - dto, company?.LogoData, company?.LogoContentType, companyInfo); + dto, logoData, logoContentType, companyInfo); return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf"); } @@ -847,4 +851,15 @@ public class PurchaseOrdersController : Controller vendors.Insert(0, new SelectListItem("All 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); + } } diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index ded5df7..610fff7 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -40,6 +40,7 @@ public class QuotesController : Controller private readonly IWebHostEnvironment _env; private readonly IJobPhotoService _jobPhotoService; private readonly IAiUsageLogger _usageLogger; + private readonly ICompanyLogoService _logoService; public QuotesController( IUnitOfWork unitOfWork, @@ -59,7 +60,8 @@ public class QuotesController : Controller IAiQuoteService aiService, IWebHostEnvironment env, IJobPhotoService jobPhotoService, - IAiUsageLogger usageLogger) + IAiUsageLogger usageLogger, + ICompanyLogoService logoService) { _unitOfWork = unitOfWork; _mapper = mapper; @@ -79,6 +81,7 @@ public class QuotesController : Controller _env = env; _jobPhotoService = jobPhotoService; _usageLogger = usageLogger; + _logoService = logoService; } /// @@ -604,10 +607,11 @@ public class QuotesController : Controller }; // Generate PDF + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); var pdfBytes = await _pdfService.GenerateQuotePdfAsync( quoteDto, - company.LogoData, - company.LogoContentType, + logoData, + logoContentType, companyInfo, template: template ); @@ -1037,13 +1041,22 @@ public class QuotesController : Controller await _unitOfWork.CompleteAsync(); // 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) { 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 (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) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); @@ -1895,13 +1908,22 @@ public class QuotesController : Controller await _unitOfWork.CompleteAsync(); // 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) { 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 (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) { var ext = Path.GetExtension(photoPath).ToLowerInvariant(); @@ -2826,10 +2848,11 @@ public class QuotesController : Controller DefaultTerms = prefs?.QtDefaultTerms }; + var (logoData, logoContentType) = await LoadCompanyLogoAsync(company); return await _pdfService.GenerateQuotePdfAsync( quoteDto, - company.LogoData, - company.LogoContentType, + logoData, + logoContentType, companyInfo, template: template); } @@ -3908,6 +3931,30 @@ public class QuotesController : Controller return Json(new { success = true }); } + /// Updates the caption of a non-AI quote photo. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 GetCompanyPreferencesAsync(int companyId) { 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); } + + /// + /// 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. + /// + 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 diff --git a/src/PowderCoating.Web/Views/Quotes/Create.cshtml b/src/PowderCoating.Web/Views/Quotes/Create.cshtml index c936832..7592de9 100644 --- a/src/PowderCoating.Web/Views/Quotes/Create.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Create.cshtml @@ -398,7 +398,7 @@ Cancel - diff --git a/src/PowderCoating.Web/Views/Quotes/Edit.cshtml b/src/PowderCoating.Web/Views/Quotes/Edit.cshtml index d1973f0..c2d0c10 100644 --- a/src/PowderCoating.Web/Views/Quotes/Edit.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Edit.cshtml @@ -363,6 +363,10 @@ style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete"> + }

@photo.FileName

@photo.CreatedAt.ToString("MMM d, yyyy")

+ @if (!photo.IsAiAnalysisPhoto) + { +

@photo.Caption

+
+ +
+ + +
+
+ } @@ -398,7 +413,7 @@
Cancel - @@ -708,6 +723,7 @@ const quoteId = @Model.Id; const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")'; const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")'; + const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")'; const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; const fileInput = document.getElementById('editPhotoFileInput'); @@ -744,12 +760,24 @@ style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete"> +

${data.fileName}

Just now

+

+
+ +
+ + +
+
`; grid.appendChild(col); @@ -771,6 +799,45 @@ 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) { const badge = document.getElementById('photoCount'); if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;