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:
@@ -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.
|
||||||
p.Sku.ToLower() == term ||
|
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
|
||||||
p.ColorName.ToLower().Contains(term) ||
|
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
|
||||||
p.Sku.ToLower().Contains(term));
|
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
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
Reference in New Issue
Block a user