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
@@ -252,11 +252,27 @@ public class PricingCalculationService : IPricingCalculationService
}; };
} }
// AI items use ManualUnitPrice directly (set to either the AI estimate or the user's price override). // AI items use ManualUnitPrice as the single-coat base price.
// The AI already factored in all costs — skip the pricing engine entirely. // Apply the same additional-coat charge as the calculated-item path so that
// adding a 2nd or 3rd coat in step 3 increases the price by AdditionalCoatLaborPercent%
// per coat — matching what catalog/calculated items charge.
if (item.IsAiItem && item.ManualUnitPrice.HasValue) if (item.IsAiItem && item.ManualUnitPrice.HasValue)
{ {
var aiUnitPrice = item.ManualUnitPrice.Value; var aiUnitPrice = item.ManualUnitPrice.Value;
int additionalAiCoats = 0;
if (item.Coats != null)
{
for (int i = 1; i < item.Coats.Count; i++)
{
if (!item.Coats[i].NoExtraLayerCharge)
additionalAiCoats++;
}
}
if (additionalAiCoats > 0)
aiUnitPrice = Math.Round(
aiUnitPrice * (1m + additionalAiCoats * (costs.AdditionalCoatLaborPercent / 100m)), 2);
var aiTotal = aiUnitPrice * item.Quantity; var aiTotal = aiUnitPrice * item.Quantity;
return new QuoteItemPricingResult return new QuoteItemPricingResult
{ {
@@ -540,6 +540,16 @@ Respond with the JSON object only.";
var complexityCharge = subtotalBeforeComplexity * complexityPct; var complexityCharge = subtotalBeforeComplexity * complexityPct;
var subtotal = subtotalBeforeComplexity + complexityCharge; var subtotal = subtotalBeforeComplexity + complexityCharge;
// Additional coat charge — each coat beyond the first adds AdditionalCoatLaborPercent % of
// the subtotal, matching the formula in PricingCalculationService.CalculateQuoteItemPriceAsync.
// The coat count here comes from the wizard's step-2 field (aiCoatCount), so the preview
// reflects whatever multi-coat configuration the user specified before clicking Analyze.
if (request.CoatCount > 1)
{
var additionalCoatCharge = subtotal * (request.CoatCount - 1) * (costs.AdditionalCoatLaborPercent / 100m);
subtotal += additionalCoatCharge;
}
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m); var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
// Apply shop minimum // Apply shop minimum
@@ -1029,12 +1029,13 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns /// Searches the platform-level PowderCatalogItems table by SKU or color name.
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back /// Excludes catalog entries already present in the company's inventory (by ManufacturerPartNumber).
/// to the AI Lookup, avoiding unnecessary API calls for known products. /// 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> /// </summary>
[HttpGet] [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) if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>()); return Json(Array.Empty<object>());
@@ -1042,18 +1043,47 @@ public class InventoryController : Controller
var term = q.Trim().ToLower(); var term = q.Trim().ToLower();
var vendorTerm = vendor?.Trim().ToLower(); var vendorTerm = vendor?.Trim().ToLower();
var matches = await _unitOfWork.PowderCatalog.FindAsync(p => // 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.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(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));
}
// 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 var results = matches
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1) .OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
.ThenBy(p => p.ColorName) .ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new .Select(p => new
{ {
id = p.Id, id = p.Id,
@@ -1075,7 +1105,8 @@ public class InventoryController : Controller
requiresClearCoat = p.RequiresClearCoat, requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb, coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency transferEfficiency = p.TransferEfficiency
}); })
.ToList();
return Json(results); return Json(results);
} }
@@ -3393,7 +3393,6 @@ public class QuotesController : Controller
/// Returns a tempId that the JS wizard tracks and submits as AiPhotoTempIds[] on form submit. /// Returns a tempId that the JS wizard tracks and submits as AiPhotoTempIds[] on form submit.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> UploadAiPhoto(IFormFile? file) public async Task<IActionResult> UploadAiPhoto(IFormFile? file)
{ {
@@ -3431,7 +3430,6 @@ public class QuotesController : Controller
/// so the model's estimates are calibrated to this company's pricing and material usage patterns. /// so the model's estimates are calibrated to this company's pricing and material usage patterns.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> AiAnalyzeItem([FromBody] AiAnalyzeItemRequest request) public async Task<IActionResult> AiAnalyzeItem([FromBody] AiAnalyzeItemRequest request)
{ {
@@ -3604,6 +3602,11 @@ public class QuotesController : Controller
var complexityCharge = subtotal * complexityPct; var complexityCharge = subtotal * complexityPct;
subtotal += complexityCharge; subtotal += complexityCharge;
// Additional coat charge — each coat beyond the first adds AdditionalCoatLaborPercent%,
// matching the formula in PricingCalculationService.CalculateQuoteItemPriceAsync.
if (request.CoatCount > 1)
subtotal += subtotal * (request.CoatCount - 1) * (costs.AdditionalCoatLaborPercent / 100m);
if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0) if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
subtotal = costs.ShopMinimumCharge; subtotal = costs.ShopMinimumCharge;
@@ -77,7 +77,7 @@
</button> </button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) @if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{ {
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="scan-label-btn" title="Scan a powder bag label with your camera"> <button type="button" class="btn btn-sm btn-outline-secondary ms-1 d-lg-none" id="scan-label-btn" title="Scan a powder bag label with your camera">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label <i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button> </button>
} }
@@ -74,12 +74,12 @@
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3"> <div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center"> <h5 class="mb-0 d-flex align-items-center">
<i class="bi bi-palette me-2 text-primary"></i>Product Details <i class="bi bi-palette me-2 text-primary"></i>Product Details
<button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn"> <button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn" data-current-id="@Model.Id">
<i class="bi bi-search me-1"></i>Lookup <i class="bi bi-search me-1"></i>Lookup
</button> </button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false)) @if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{ {
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="scan-label-btn" title="Scan a powder bag label with your camera"> <button type="button" class="btn btn-sm btn-outline-secondary ms-1 d-lg-none" id="scan-label-btn" title="Scan a powder bag label with your camera">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label <i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button> </button>
} }
@@ -101,6 +101,12 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Powder Coating Logix</title> <title>@ViewData["Title"] - Powder Coating Logix</title>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1A1A1C" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PCLogix" />
<!-- First-paint theme: server already stamped data-surface from cookie. <!-- First-paint theme: server already stamped data-surface from cookie.
This script only corrects first-visit (no cookie) using OS preference. --> This script only corrects first-visit (no cookie) using OS preference. -->
@@ -2192,5 +2198,10 @@
}, true); }, true);
})(); })();
</script> </script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -54,6 +54,8 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchTerm) params.set('q', searchTerm); if (searchTerm) params.set('q', searchTerm);
if (manufacturer) params.set('vendor', manufacturer); if (manufacturer) params.set('vendor', manufacturer);
const currentId = smartBtn.dataset.currentId;
if (currentId) params.set('currentId', currentId);
const resp = await fetch(`${LOOKUP_URL}?${params}`); const resp = await fetch(`${LOOKUP_URL}?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -358,7 +360,7 @@
<div class="modal-dialog modal-dialog-scrollable"> <div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header py-2"> <div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-list-ul me-2"></i>Multiple matches pick one</h6> <h6 class="modal-title"><i class="bi bi-list-ul me-2"></i>${items.length} match${items.length !== 1 ? 'es' : ''} pick one <span class="text-muted fw-normal small">(already in inventory excluded)</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body p-0"> <div class="modal-body p-0">
@@ -935,7 +935,9 @@ async function _aiRecalcPriceAsync() {
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || b.surfaceAreaSqFt; const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || b.surfaceAreaSqFt;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || b.estimatedMinutes; const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || b.estimatedMinutes;
const complexity = document.getElementById('ai_complexityOverride')?.value || b.complexity; const complexity = document.getElementById('ai_complexityOverride')?.value || b.complexity;
const coatCount = b.coatCount || 1; // Use the actual step-3 coat count so the price preview reflects whatever the user
// added in the coating layers screen, not the coat count fixed at AI analysis time.
const coatCount = (wz.data.coats || []).length || b.coatCount || 1;
const recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice'; const recalcUrl = pageMeta.aiRecalcUrl || '/Quotes/AiRecalcPrice';
const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''; const csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
@@ -1280,14 +1282,22 @@ async function aiAnalyze() {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`Server returned ${resp.status} ${resp.statusText}`); if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
}
throw new Error(`Server error (${resp.status}). Please try again.`);
}
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
// Server returned HTML (e.g. login redirect) instead of JSON
throw new Error('Your session may have expired. Please refresh the page and sign in again.');
} }
const result = await resp.json(); const result = await resp.json();
aiHandleResult(result); aiHandleResult(result);
} catch (err) { } catch (err) {
console.error('AI analyze error:', err); console.error('AI analyze error:', err);
aiSetLoading(false); aiSetLoading(false);
aiShowError('Error: ' + err.message); aiShowError(err.message);
} }
} }
@@ -1334,14 +1344,21 @@ async function aiSendFollowup() {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`Server returned ${resp.status} ${resp.statusText}`); if (resp.status === 401 || resp.status === 302 || resp.redirected) {
throw new Error('Your session has expired. Please refresh the page and sign in again.');
}
throw new Error(`Server error (${resp.status}). Please try again.`);
}
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/json')) {
throw new Error('Your session may have expired. Please refresh the page and sign in again.');
} }
const result = await resp.json(); const result = await resp.json();
aiHandleResult(result); aiHandleResult(result);
} catch (err) { } catch (err) {
console.error('AI follow-up error:', err); console.error('AI follow-up error:', err);
aiSetLoading(false); aiSetLoading(false);
aiShowError('Error: ' + err.message); aiShowError(err.message);
} }
} }
@@ -1363,6 +1380,9 @@ function aiHandleResult(result) {
wz.ai.followUpQuestion = result.followUpQuestion || 'Can you provide more details?'; wz.ai.followUpQuestion = result.followUpQuestion || 'Can you provide more details?';
renderStep(wz.step); renderStep(wz.step);
document.getElementById('ai_followupAnswer')?.focus(); document.getElementById('ai_followupAnswer')?.focus();
requestAnimationFrame(() => {
document.getElementById('ai_followupSection')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
} else { } else {
// Store result state and re-render step so elements are guaranteed fresh // Store result state and re-render step so elements are guaranteed fresh
wz.ai.result = result; wz.ai.result = result;
@@ -1371,6 +1391,9 @@ function aiHandleResult(result) {
wz.ai.phase = 'result'; wz.ai.phase = 'result';
renderStep(wz.step); renderStep(wz.step);
document.getElementById('ai_acceptError')?.classList.add('d-none'); document.getElementById('ai_acceptError')?.classList.add('d-none');
requestAnimationFrame(() => {
document.getElementById('ai_resultsSection')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
} }
} }
@@ -1488,6 +1511,8 @@ function addCoatRow() {
if (!container) return; if (!container) return;
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(i, coat)); container.insertAdjacentHTML('beforeend', buildCoatRowHtml(i, coat));
updatePowderNeeded(i); updatePowderNeeded(i);
// AI items: recalculate price preview so multi-coat surcharge is visible before saving
if (wz.itemType === 'ai') aiRecalcPrice();
} }
const COAT_NAME_PRESETS = ['Primer', 'Base Coat', 'Mid Coat', 'Top Coat', 'Clear Coat']; const COAT_NAME_PRESETS = ['Primer', 'Base Coat', 'Mid Coat', 'Top Coat', 'Clear Coat'];
@@ -1699,6 +1724,8 @@ function removeCoatRow(i) {
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(idx, coat)); container.insertAdjacentHTML('beforeend', buildCoatRowHtml(idx, coat));
restoreCoatRow(idx, coat); restoreCoatRow(idx, coat);
}); });
updateAllPowderNeeded();
if (wz.itemType === 'ai') aiRecalcPrice();
} }
@@ -0,0 +1,24 @@
{
"name": "Powder Coating Logix",
"short_name": "PCLogix",
"description": "Powder coating shop management — jobs, quotes, inventory, and scheduling.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1A1A1C",
"orientation": "any",
"icons": [
{
"src": "/images/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/images/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
// Minimal service worker — required for PWA installability.
// No caching: all requests pass through to the network normally.
// This exists solely so browsers recognize the site as installable,
// which causes iOS/Android to persist camera permissions after "Add to Home Screen."
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', e => e.respondWith(fetch(e.request)));