Merge dev into master: churned account filter, powder catalog lookup fixes
This commit is contained in:
@@ -31,10 +31,13 @@ public interface ICompanyListService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
/// 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 <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||||
|
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
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
|
var query = _context.Companies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.AsQueryable();
|
.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))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var s = searchTerm.ToLower();
|
var s = searchTerm.ToLower();
|
||||||
@@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return (companies, totalCount);
|
return (companies, totalCount, churnedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
|||||||
string sortColumn = "CompanyName",
|
string sortColumn = "CompanyName",
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 25)
|
int pageSize = 25,
|
||||||
|
bool showChurned = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||||
|
|
||||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||||
|
|
||||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||||
|
|
||||||
@@ -128,6 +129,8 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
return View(companyDtos);
|
return View(companyDtos);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
|||||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
|
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var d30 = now.AddDays(-30);
|
var d30 = now.AddDays(-30);
|
||||||
var d90 = now.AddDays(-90);
|
var d90 = now.AddDays(-90);
|
||||||
|
var churnedCutoff = now.AddDays(-14);
|
||||||
|
|
||||||
// One query per signal — all keyed by CompanyId
|
// One query per signal — all keyed by CompanyId
|
||||||
var companies = await _db.Companies
|
var allCompanies = await _db.Companies
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.ToListAsync();
|
.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
|
var lastLogins = await _db.Users
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(u => u.LastLoginDate != null)
|
.Where(u => u.LastLoginDate != null)
|
||||||
@@ -163,6 +175,8 @@ public class CompanyHealthController : Controller
|
|||||||
ViewBag.Risk = risk;
|
ViewBag.Risk = risk;
|
||||||
ViewBag.Search = search;
|
ViewBag.Search = search;
|
||||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
all = all.Where(h =>
|
all = all.Where(h =>
|
||||||
|
|||||||
@@ -304,6 +304,32 @@ public class InventoryController : Controller
|
|||||||
await _unitOfWork.SaveChangesAsync();
|
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.";
|
TempData["Success"] = "Inventory item created successfully.";
|
||||||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
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." });
|
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||||
|
|
||||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||||
|
if (result.Success)
|
||||||
|
await EnrichFromCatalogAsync(result, autoContribute: true);
|
||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +778,39 @@ public class InventoryController : Controller
|
|||||||
result.SdsUrl ??= match.SdsUrl;
|
result.SdsUrl ??= match.SdsUrl;
|
||||||
result.TdsUrl ??= match.TdsUrl;
|
result.TdsUrl ??= match.TdsUrl;
|
||||||
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
|
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);
|
return (true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +828,7 @@ public class InventoryController : Controller
|
|||||||
VendorName = manufacturer,
|
VendorName = manufacturer,
|
||||||
Sku = sku,
|
Sku = sku,
|
||||||
ColorName = colorName,
|
ColorName = colorName,
|
||||||
|
UnitPrice = result.UnitCostPerLb ?? 0m,
|
||||||
CureTemperatureF = result.CureTemperatureF,
|
CureTemperatureF = result.CureTemperatureF,
|
||||||
CureTimeMinutes = result.CureTimeMinutes,
|
CureTimeMinutes = result.CureTimeMinutes,
|
||||||
Finish = result.Finish,
|
Finish = result.Finish,
|
||||||
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
|
|||||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
|
// Single query — all partial color/SKU matches across all vendors.
|
||||||
// if the scoped search returns nothing — prevents a cross-vendor color match from
|
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
|
||||||
// being returned as the only result when the user clearly intended a specific manufacturer.
|
// triggers auto-fill in the JS. Everything else goes to the picker modal.
|
||||||
IEnumerable<PowderCatalogItem> matches;
|
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
|
||||||
if (!string.IsNullOrEmpty(vendorTerm))
|
// 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.
|
||||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||||
p.VendorName.ToLower().Contains(vendorTerm) && (
|
|
||||||
p.Sku.ToLower() == term ||
|
|
||||||
p.ColorName.ToLower().Contains(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.Sku.ToLower() == term ||
|
||||||
p.ColorName.ToLower().Contains(term) ||
|
|
||||||
p.Sku.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));
|
|
||||||
}
|
|
||||||
|
|
||||||
var results = matches
|
var results = matches
|
||||||
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
||||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
.Select(p =>
|
||||||
.ThenBy(p => p.ColorName)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
{
|
||||||
id = p.Id,
|
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
|
||||||
vendorName = p.VendorName,
|
var colorExact = p.ColorName.ToLower() == term;
|
||||||
sku = p.Sku,
|
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
|
||||||
colorName = p.ColorName,
|
})
|
||||||
description = p.Description,
|
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
|
||||||
unitPrice = p.UnitPrice,
|
.ThenBy(x => x.p.ColorName)
|
||||||
imageUrl = p.ImageUrl,
|
.Select(x => new
|
||||||
sdsUrl = p.SdsUrl,
|
{
|
||||||
tdsUrl = p.TdsUrl,
|
id = x.p.Id,
|
||||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
vendorName = x.p.VendorName,
|
||||||
productUrl = p.ProductUrl,
|
sku = x.p.Sku,
|
||||||
isDiscontinued = p.IsDiscontinued,
|
colorName = x.p.ColorName,
|
||||||
cureTemperatureF = p.CureTemperatureF,
|
description = x.p.Description,
|
||||||
cureTimeMinutes = p.CureTimeMinutes,
|
unitPrice = x.p.UnitPrice,
|
||||||
finish = p.Finish,
|
imageUrl = x.p.ImageUrl,
|
||||||
colorFamilies = p.ColorFamilies,
|
sdsUrl = x.p.SdsUrl,
|
||||||
requiresClearCoat = p.RequiresClearCoat,
|
tdsUrl = x.p.TdsUrl,
|
||||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
applicationGuideUrl = x.p.ApplicationGuideUrl,
|
||||||
specificGravity = p.SpecificGravity,
|
productUrl = x.p.ProductUrl,
|
||||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
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();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,13 @@
|
|||||||
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
var totalPages = (int)(ViewBag.TotalPages ?? 1);
|
||||||
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
var totalCount = (int)(ViewBag.TotalCount ?? 0);
|
||||||
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
|
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
|
||||||
|
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||||
|
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||||
|
|
||||||
string SortLink(string col)
|
string SortLink(string col)
|
||||||
{
|
{
|
||||||
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
|
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)
|
string SortIcon(string col)
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
<input type="hidden" name="sortColumn" value="@sortColumn" />
|
<input type="hidden" name="sortColumn" value="@sortColumn" />
|
||||||
<input type="hidden" name="sortDirection" value="@sortDirection" />
|
<input type="hidden" name="sortDirection" value="@sortDirection" />
|
||||||
<input type="hidden" name="pageSize" value="@pageSize" />
|
<input type="hidden" name="pageSize" value="@pageSize" />
|
||||||
|
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
@@ -75,6 +78,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (churnedCount > 0 && !showChurned)
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||||
|
<i class="bi bi-eye-slash text-muted"></i>
|
||||||
|
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden.</span>
|
||||||
|
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = true })"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (showChurned && churnedCount > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||||
|
<i class="bi bi-eye text-warning"></i>
|
||||||
|
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
|
||||||
|
<a href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = 1, pageSize, showChurned = false })"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (Model != null && Model.Any())
|
@if (Model != null && Model.Any())
|
||||||
@@ -313,18 +335,18 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination pagination-sm mb-0">
|
<ul class="pagination pagination-sm mb-0">
|
||||||
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
|
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
|
||||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize })">
|
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize, showChurned })">
|
||||||
<i class="bi bi-chevron-left"></i>
|
<i class="bi bi-chevron-left"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
|
||||||
{
|
{
|
||||||
<li class="page-item @(p == pageNumber ? "active" : "")">
|
<li class="page-item @(p == pageNumber ? "active" : "")">
|
||||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize })">@p</a>
|
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize, showChurned })">@p</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
|
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
|
||||||
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize })">
|
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize, showChurned })">
|
||||||
<i class="bi bi-chevron-right"></i>
|
<i class="bi bi-chevron-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -464,6 +486,7 @@
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('pageSize', size);
|
url.searchParams.set('pageSize', size);
|
||||||
url.searchParams.set('pageNumber', '1');
|
url.searchParams.set('pageNumber', '1');
|
||||||
|
url.searchParams.set('showChurned', '@showChurned.ToString().ToLower()');
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Company Health";
|
ViewData["Title"] = "Company Health";
|
||||||
|
|
||||||
|
var showChurned = (bool)(ViewBag.ShowChurned ?? false);
|
||||||
|
var churnedCount = (int)(ViewBag.ChurnedCount ?? 0);
|
||||||
|
|
||||||
string RiskBadge(ChurnRisk r) => r switch {
|
string RiskBadge(ChurnRisk r) => r switch {
|
||||||
ChurnRisk.Healthy => "bg-success",
|
ChurnRisk.Healthy => "bg-success",
|
||||||
ChurnRisk.AtRisk => "bg-warning text-dark",
|
ChurnRisk.AtRisk => "bg-warning text-dark",
|
||||||
@@ -73,6 +76,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Churned account visibility banner *@
|
||||||
|
@if (churnedCount > 0 && !showChurned)
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||||
|
<i class="bi bi-eye-slash text-muted"></i>
|
||||||
|
<span class="small"><strong>@churnedCount</strong> churned @(churnedCount == 1 ? "account" : "accounts") (expired or canceled 14+ days ago) hidden from scores and totals.</span>
|
||||||
|
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = true })"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Show churned</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (showChurned && churnedCount > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-3 py-2">
|
||||||
|
<i class="bi bi-eye text-warning"></i>
|
||||||
|
<span class="small">Showing all accounts including <strong>@churnedCount</strong> churned.</span>
|
||||||
|
<a href="@Url.Action("Index", new { risk = ViewBag.Risk, search = ViewBag.Search, configIssuesOnly = ViewBag.ConfigIssuesOnly, showChurned = false })"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-auto py-0">Hide churned</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Summary stat cards *@
|
@* Summary stat cards *@
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
@@ -193,6 +216,7 @@
|
|||||||
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
<label class="form-check-label small" for="configOnly">Config issues only</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="showChurned" value="@showChurned.ToString().ToLower()" />
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button class="btn btn-sm btn-primary">Filter</button>
|
<button class="btn btn-sm btn-primary">Filter</button>
|
||||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
||||||
|
|||||||
@@ -62,23 +62,25 @@
|
|||||||
const items = await resp.json();
|
const items = await resp.json();
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
// No catalog match — fall back to AI if available
|
// Nothing in catalog — go straight to AI
|
||||||
hideStatus();
|
await runAiOrWarn();
|
||||||
if (typeof window._runInventoryAiLookup === 'function') {
|
|
||||||
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>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.');
|
|
||||||
}
|
|
||||||
return;
|
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]);
|
await fillFields(items[0]);
|
||||||
return;
|
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();
|
hideStatus();
|
||||||
showPickerModal(items);
|
showPickerModal(items);
|
||||||
|
|
||||||
@@ -89,6 +91,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── AI fallback helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runAiOrWarn() {
|
||||||
|
hideStatus();
|
||||||
|
if (typeof window._runInventoryAiLookup === 'function') {
|
||||||
|
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>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 ────────────────────────────────────
|
// ── Fill fields from a catalog result ────────────────────────────────────
|
||||||
|
|
||||||
async function fillFields(item) {
|
async function fillFields(item) {
|
||||||
@@ -368,6 +382,12 @@
|
|||||||
<div class="modal-body p-0">
|
<div class="modal-body p-0">
|
||||||
<div class="list-group list-group-flush">${rows}</div>
|
<div class="list-group list-group-flush">${rows}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-footer py-2 justify-content-start">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="catalogPickerNotListed">
|
||||||
|
<i class="bi bi-search me-1"></i>Not listed — search online
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small ms-2">Uses AI to look up the exact product</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -383,6 +403,11 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('catalogPickerNotListed').addEventListener('click', function () {
|
||||||
|
bsModal.hide();
|
||||||
|
runAiOrWarn();
|
||||||
|
});
|
||||||
|
|
||||||
bsModal.show();
|
bsModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user