Add PWA manifest, fix AI multi-coat pricing, and improve catalog lookup

- PWA: manifest.json + minimal service worker so iOS/Android persist camera
  permission after "Add to Home Screen"; theme-color and apple meta tags in layout
- PWA icons: 192x192 and 512x512 from transparent PCL logo; updated pcl-logo.png
- AI pricing: apply AdditionalCoatLaborPercent per extra coat on AI items,
  matching the calculated-item path (was ignoring extra coats entirely)
- AI wizard: live price recalc when coats are added/removed; session-expiry
  errors now show a clear "refresh and sign in" message instead of raw HTTP status;
  smooth-scroll to follow-up/results sections on AI response
- Catalog lookup: exclude SKUs already in company inventory from results;
  pass currentId on edit so own entry still appears; vendor-scoped search
  with cross-vendor fallback; result count shown in multi-match modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 08:58:10 -04:00
parent 7de65910e3
commit 50b1794799
16 changed files with 158 additions and 26 deletions
@@ -1029,12 +1029,13 @@ public class InventoryController : Controller
}
/// <summary>
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
/// to the AI Lookup, avoiding unnecessary API calls for known products.
/// Searches the platform-level PowderCatalogItems table by SKU or color name.
/// Excludes catalog entries already present in the company's inventory (by ManufacturerPartNumber).
/// Pass currentId when editing an existing item so its own catalog entry is not filtered out.
/// Called by the inventory Create/Edit form before falling back to AI Lookup.
/// </summary>
[HttpGet]
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
public async Task<IActionResult> CatalogLookup(string? q, string? vendor, int? currentId = null)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>());
@@ -1042,18 +1043,47 @@ public class InventoryController : Controller
var term = q.Trim().ToLower();
var vendorTerm = vendor?.Trim().ToLower();
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
// Build a set of SKUs already in this company's inventory so we can exclude them.
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.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<PowderCatalogItem> 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));
}
// When a vendor hint is provided, prefer records where VendorName matches,
// then fall back to all results so the user still sees cross-vendor options.
var results = matches
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new
{
id = p.Id,
@@ -1075,7 +1105,8 @@ public class InventoryController : Controller
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency
});
})
.ToList();
return Json(results);
}