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
@@ -935,7 +935,9 @@ async function _aiRecalcPriceAsync() {
const sqft = parseFloat(document.getElementById('ai_sqftOverride')?.value) || b.surfaceAreaSqFt;
const minutes = parseInt(document.getElementById('ai_minutesOverride')?.value) || b.estimatedMinutes;
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 csrf = document.querySelector('input[name="__RequestVerificationToken"]')?.value || '';
@@ -1280,14 +1282,22 @@ async function aiAnalyze() {
body: JSON.stringify(payload)
});
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();
aiHandleResult(result);
} catch (err) {
console.error('AI analyze error:', err);
aiSetLoading(false);
aiShowError('Error: ' + err.message);
aiShowError(err.message);
}
}
@@ -1334,14 +1344,21 @@ async function aiSendFollowup() {
body: JSON.stringify(payload)
});
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();
aiHandleResult(result);
} catch (err) {
console.error('AI follow-up error:', err);
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?';
renderStep(wz.step);
document.getElementById('ai_followupAnswer')?.focus();
requestAnimationFrame(() => {
document.getElementById('ai_followupSection')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
} else {
// Store result state and re-render step so elements are guaranteed fresh
wz.ai.result = result;
@@ -1371,6 +1391,9 @@ function aiHandleResult(result) {
wz.ai.phase = 'result';
renderStep(wz.step);
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;
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(i, coat));
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'];
@@ -1699,6 +1724,8 @@ function removeCoatRow(i) {
container.insertAdjacentHTML('beforeend', buildCoatRowHtml(idx, coat));
restoreCoatRow(idx, coat);
});
updateAllPowderNeeded();
if (wz.itemType === 'ai') aiRecalcPrice();
}