diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs index 37785d3..75a20b3 100644 --- a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs +++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs @@ -31,10 +31,13 @@ public interface ICompanyListService { /// /// Returns a paged, searched, and sorted slice of non-deleted companies together with the - /// total unfiltered count for pagination. + /// total count for pagination and the count of churned accounts that are currently hidden. + /// When is true, Expired/Canceled companies whose subscription + /// ended more than 14 days ago are excluded from results (but still counted for the banner). /// - Task<(List Companies, int TotalCount)> GetPagedAsync( - string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize); + Task<(List Companies, int TotalCount, int ChurnedCount)> GetPagedAsync( + string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize, + bool hideChurned = true); /// /// Returns job, quote, customer, and wizard completion counts for each of the supplied diff --git a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs index 55b3967..5236079 100644 --- a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs +++ b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces.Services; using PowderCoating.Infrastructure.Data; @@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService } /// - public async Task<(List Companies, int TotalCount)> GetPagedAsync( - string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize) + public async Task<(List Companies, int TotalCount, int ChurnedCount)> GetPagedAsync( + string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize, + bool hideChurned = true) { + var cutoff = DateTime.UtcNow.AddDays(-14); + + // Always count churned regardless of hideChurned so the banner can show a number. + var churnedCount = await _context.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => !c.IsDeleted + && (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) + && c.SubscriptionEndDate != null + && c.SubscriptionEndDate < cutoff) + .CountAsync(); + var query = _context.Companies .AsNoTracking() .IgnoreQueryFilters() .Where(c => !c.IsDeleted) .AsQueryable(); + if (hideChurned) + query = query.Where(c => + !((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) + && c.SubscriptionEndDate != null + && c.SubscriptionEndDate < cutoff)); + if (!string.IsNullOrWhiteSpace(searchTerm)) { var s = searchTerm.ToLower(); @@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService .Take(pageSize) .ToListAsync(); - return (companies, totalCount); + return (companies, totalCount, churnedCount); } /// diff --git a/src/PowderCoating.Web/Controllers/CompaniesController.cs b/src/PowderCoating.Web/Controllers/CompaniesController.cs index 09d683f..3e20868 100644 --- a/src/PowderCoating.Web/Controllers/CompaniesController.cs +++ b/src/PowderCoating.Web/Controllers/CompaniesController.cs @@ -66,15 +66,16 @@ public class CompaniesController : Controller string sortColumn = "CompanyName", string sortDirection = "asc", int pageNumber = 1, - int pageSize = 25) + int pageSize = 25, + bool showChurned = false) { try { pageNumber = Math.Max(1, pageNumber); pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25; - var (companies, totalCount) = await _companyList.GetPagedAsync( - searchTerm, sortColumn, sortDirection, pageNumber, pageSize); + var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync( + searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned); var companyDtos = _mapper.Map>(companies); @@ -128,6 +129,8 @@ public class CompaniesController : Controller ViewBag.PageSize = pageSize; ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId"); + ViewBag.ShowChurned = showChurned; + ViewBag.ChurnedCount = churnedCount; return View(companyDtos); } diff --git a/src/PowderCoating.Web/Controllers/CompanyHealthController.cs b/src/PowderCoating.Web/Controllers/CompanyHealthController.cs index feb3ede..af2cd59 100644 --- a/src/PowderCoating.Web/Controllers/CompanyHealthController.cs +++ b/src/PowderCoating.Web/Controllers/CompanyHealthController.cs @@ -45,18 +45,30 @@ public class CompanyHealthController : Controller /// user's risk/search filters, so the KPI cards always show platform-wide totals. /// /// - public async Task Index(string? risk, string? search, bool configIssuesOnly = false) + public async Task Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false) { var now = DateTime.UtcNow; var d30 = now.AddDays(-30); var d90 = now.AddDays(-90); + var churnedCutoff = now.AddDays(-14); // One query per signal — all keyed by CompanyId - var companies = await _db.Companies + var allCompanies = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted) .ToListAsync(); + var churnedCount = allCompanies.Count(c => + (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) + && c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff); + + var companies = showChurned + ? allCompanies + : allCompanies.Where(c => + !((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) + && c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff)) + .ToList(); + var lastLogins = await _db.Users .AsNoTracking().IgnoreQueryFilters() .Where(u => u.LastLoginDate != null) @@ -163,6 +175,8 @@ public class CompanyHealthController : Controller ViewBag.Risk = risk; ViewBag.Search = search; ViewBag.ConfigIssuesOnly = configIssuesOnly; + ViewBag.ShowChurned = showChurned; + ViewBag.ChurnedCount = churnedCount; if (!string.IsNullOrWhiteSpace(search)) all = all.Where(h => diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 17e0a35..107f72e 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -304,6 +304,32 @@ public class InventoryController : Controller await _unitOfWork.SaveChangesAsync(); } + // Contribute/sync to the platform powder catalog if we have enough identity data. + // Runs silently — a failure here never blocks the inventory save. + if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber)) + { + var catalogResult = new InventoryAiLookupResult + { + Manufacturer = dto.Manufacturer, + ManufacturerPartNumber = dto.ManufacturerPartNumber, + ColorName = dto.ColorName ?? item.Name, + Finish = dto.Finish, + CureTemperatureF = dto.CureTemperatureF, + CureTimeMinutes = dto.CureTimeMinutes, + ColorFamilies = dto.ColorFamilies, + RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null, + CoverageSqFtPerLb = dto.CoverageSqFtPerLb, + SpecificGravity = dto.SpecificGravity, + TransferEfficiency = dto.TransferEfficiency, + UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null, + SpecPageUrl = dto.SpecPageUrl, + ImageUrl = dto.ImageUrl, + SdsUrl = dto.SdsUrl, + TdsUrl = dto.TdsUrl, + }; + await EnrichFromCatalogAsync(catalogResult, autoContribute: true); + } + TempData["Success"] = "Inventory item created successfully."; return RedirectToAction(nameof(Details), new { id = item.Id }); } @@ -704,6 +730,8 @@ public class InventoryController : Controller return Json(new { success = false, errorMessage = "No product URL provided." }); var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName); + if (result.Success) + await EnrichFromCatalogAsync(result, autoContribute: true); return Json(result); } @@ -750,6 +778,39 @@ public class InventoryController : Controller result.SdsUrl ??= match.SdsUrl; result.TdsUrl ??= match.TdsUrl; if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice; + + // Back-sync: fill NULL catalog fields from the incoming result so the catalog + // gets richer over time without overwriting anything already stored. + bool catalogDirty = false; + if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; } + if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; } + if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; } + if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; } + if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; } + if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; } + if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; } + if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; } + if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; } + if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; } + if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; } + if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; } + if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; } + + if (catalogDirty) + { + match.UpdatedAt = DateTime.UtcNow; + try + { + await _unitOfWork.PowderCatalog.UpdateAsync(match); + await _unitOfWork.CompleteAsync(); + _logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id); + } + } + return (true, false); } @@ -767,6 +828,7 @@ public class InventoryController : Controller VendorName = manufacturer, Sku = sku, ColorName = colorName, + UnitPrice = result.UnitCostPerLb ?? 0m, CureTemperatureF = result.CureTemperatureF, CureTimeMinutes = result.CureTimeMinutes, Finish = result.Finish, @@ -1050,61 +1112,50 @@ public class InventoryController : Controller .Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) .ToHashSet(); - // When a vendor is specified, search vendor-scoped first. Only widen to all vendors - // if the scoped search returns nothing — prevents a cross-vendor color match from - // being returned as the only result when the user clearly intended a specific manufacturer. - IEnumerable matches; - if (!string.IsNullOrEmpty(vendorTerm)) - { - matches = await _unitOfWork.PowderCatalog.FindAsync(p => - p.VendorName.ToLower().Contains(vendorTerm) && ( - p.Sku.ToLower() == term || - p.ColorName.ToLower().Contains(term) || - p.Sku.ToLower().Contains(term))); - - // Fall back to all vendors only when the scoped search finds nothing - if (!matches.Any()) - { - matches = await _unitOfWork.PowderCatalog.FindAsync(p => - p.Sku.ToLower() == term || - p.ColorName.ToLower().Contains(term) || - p.Sku.ToLower().Contains(term)); - } - } - else - { - matches = await _unitOfWork.PowderCatalog.FindAsync(p => - p.Sku.ToLower() == term || - p.ColorName.ToLower().Contains(term) || - p.Sku.ToLower().Contains(term)); - } + // Single query — all partial color/SKU matches across all vendors. + // Results are ranked: exact vendor + exact color (isExact=true) sorts first and + // triggers auto-fill in the JS. Everything else goes to the picker modal. + // This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill + // only when that exact product is in the catalog; otherwise they see a ranked modal + // with same-vendor results at the top and a "Not Listed — Search Online" escape hatch. + var matches = await _unitOfWork.PowderCatalog.FindAsync(p => + p.ColorName.ToLower().Contains(term) || + p.Sku.ToLower() == term || + p.Sku.ToLower().Contains(term)); var results = matches .Where(p => !existingSkus.Contains(p.Sku.ToLower())) - .OrderBy(p => p.Sku.ToLower() == term ? 0 : 1) - .ThenBy(p => p.ColorName) - .Select(p => new + .Select(p => { - id = p.Id, - vendorName = p.VendorName, - sku = p.Sku, - colorName = p.ColorName, - description = p.Description, - unitPrice = p.UnitPrice, - imageUrl = p.ImageUrl, - sdsUrl = p.SdsUrl, - tdsUrl = p.TdsUrl, - applicationGuideUrl = p.ApplicationGuideUrl, - productUrl = p.ProductUrl, - isDiscontinued = p.IsDiscontinued, - cureTemperatureF = p.CureTemperatureF, - cureTimeMinutes = p.CureTimeMinutes, - finish = p.Finish, - colorFamilies = p.ColorFamilies, - requiresClearCoat = p.RequiresClearCoat, - coverageSqFtPerLb = p.CoverageSqFtPerLb, - specificGravity = p.SpecificGravity, - transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency) + var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm); + var colorExact = p.ColorName.ToLower() == term; + return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact); + }) + .OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3) + .ThenBy(x => x.p.ColorName) + .Select(x => new + { + id = x.p.Id, + vendorName = x.p.VendorName, + sku = x.p.Sku, + colorName = x.p.ColorName, + description = x.p.Description, + unitPrice = x.p.UnitPrice, + imageUrl = x.p.ImageUrl, + sdsUrl = x.p.SdsUrl, + tdsUrl = x.p.TdsUrl, + applicationGuideUrl = x.p.ApplicationGuideUrl, + productUrl = x.p.ProductUrl, + isDiscontinued = x.p.IsDiscontinued, + isExact = x.isExact, + cureTemperatureF = x.p.CureTemperatureF, + cureTimeMinutes = x.p.CureTimeMinutes, + finish = x.p.Finish, + colorFamilies = x.p.ColorFamilies, + requiresClearCoat = x.p.RequiresClearCoat, + coverageSqFtPerLb = x.p.CoverageSqFtPerLb, + specificGravity = x.p.SpecificGravity, + transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency) }) .ToList(); diff --git a/src/PowderCoating.Web/Views/Companies/Index.cshtml b/src/PowderCoating.Web/Views/Companies/Index.cshtml index 5965b3c..634ae2d 100644 --- a/src/PowderCoating.Web/Views/Companies/Index.cshtml +++ b/src/PowderCoating.Web/Views/Companies/Index.cshtml @@ -26,11 +26,13 @@ var totalPages = (int)(ViewBag.TotalPages ?? 1); var totalCount = (int)(ViewBag.TotalCount ?? 0); var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId); + var showChurned = (bool)(ViewBag.ShowChurned ?? false); + var churnedCount = (int)(ViewBag.ChurnedCount ?? 0); string SortLink(string col) { var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc"; - return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize })!; + return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize, showChurned })!; } string SortIcon(string col) @@ -54,6 +56,7 @@ +
@@ -75,6 +78,25 @@
+ @if (churnedCount > 0 && !showChurned) + { +
+ + @churnedCount churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden. + Show churned +
+ } + else if (showChurned && churnedCount > 0) + { +
+ + Showing all accounts including @churnedCount churned. + Hide churned +
+ } +
@if (Model != null && Model.Any()) @@ -313,18 +335,18 @@
+ @* Churned account visibility banner *@ + @if (churnedCount > 0 && !showChurned) + { +
+ + @churnedCount churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden from scores and totals. + Show churned +
+ } + else if (showChurned && churnedCount > 0) + { +
+ + Showing all accounts including @churnedCount churned. + Hide churned +
+ } + @* Summary stat cards *@
@@ -193,6 +216,7 @@
+
Clear diff --git a/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js b/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js index 6c845d3..7748a31 100644 --- a/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js +++ b/src/PowderCoating.Web/wwwroot/js/inventory-catalog-lookup.js @@ -62,23 +62,25 @@ const items = await resp.json(); if (items.length === 0) { - // No catalog match — fall back to AI if available - hideStatus(); - if (typeof window._runInventoryAiLookup === 'function') { - showStatus('info', 'Not in catalog — searching with AI…'); - await window._runInventoryAiLookup(); - } else { - showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.'); - } + // Nothing in catalog — go straight to AI + await runAiOrWarn(); return; } - if (items.length === 1) { + // Single exact match (vendor + color name both match precisely) — auto-fill + if (items.length === 1 && items[0].isExact) { await fillFields(items[0]); return; } - // Multiple matches — let the user pick via modal + // Exact match exists but so do other results — auto-fill the exact one + const exactMatches = items.filter(i => i.isExact); + if (exactMatches.length === 1) { + await fillFields(exactMatches[0]); + return; + } + + // No exact match (or ambiguous) — show picker modal with "Not Listed" escape hatch hideStatus(); showPickerModal(items); @@ -89,6 +91,18 @@ } }); + // ── AI fallback helper ─────────────────────────────────────────────────── + + async function runAiOrWarn() { + hideStatus(); + if (typeof window._runInventoryAiLookup === 'function') { + showStatus('info', 'Not in catalog — searching online with AI…'); + await window._runInventoryAiLookup(); + } else { + showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.'); + } + } + // ── Fill fields from a catalog result ──────────────────────────────────── async function fillFields(item) { @@ -368,6 +382,12 @@ +
`; @@ -383,6 +403,11 @@ }); }); + document.getElementById('catalogPickerNotListed').addEventListener('click', function () { + bsModal.hide(); + runAiOrWarn(); + }); + bsModal.show(); }