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..fe160cb 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, 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 @@ - + @for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++) { - @p + @p } - + @@ -464,6 +486,7 @@ const url = new URL(window.location.href); url.searchParams.set('pageSize', size); url.searchParams.set('pageNumber', '1'); + url.searchParams.set('showChurned', '@showChurned.ToString().ToLower()'); window.location.href = url.toString(); } diff --git a/src/PowderCoating.Web/Views/CompanyHealth/Index.cshtml b/src/PowderCoating.Web/Views/CompanyHealth/Index.cshtml index 5c06bcc..9519969 100644 --- a/src/PowderCoating.Web/Views/CompanyHealth/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanyHealth/Index.cshtml @@ -4,6 +4,9 @@ @{ ViewData["Title"] = "Company Health"; + var showChurned = (bool)(ViewBag.ShowChurned ?? false); + var churnedCount = (int)(ViewBag.ChurnedCount ?? 0); + string RiskBadge(ChurnRisk r) => r switch { ChurnRisk.Healthy => "bg-success", ChurnRisk.AtRisk => "bg-warning text-dark", @@ -73,6 +76,26 @@ + @* 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 @@ Config issues only + Filter Clear