Merge branch 'dev'

This commit is contained in:
2026-05-04 22:14:55 -04:00
55 changed files with 171588 additions and 47 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).
// The AI already factored in all costs — skip the pricing engine entirely.
// AI items use ManualUnitPrice as the single-coat base price.
// 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)
{
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;
return new QuoteItemPricingResult
{
@@ -540,6 +540,16 @@ Respond with the JSON object only.";
var complexityCharge = subtotalBeforeComplexity * complexityPct;
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);
// Apply shop minimum
@@ -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);
}
@@ -3393,7 +3393,6 @@ public class QuotesController : Controller
/// Returns a tempId that the JS wizard tracks and submits as AiPhotoTempIds[] on form submit.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> AiAnalyzeItem([FromBody] AiAnalyzeItemRequest request)
{
@@ -3604,6 +3602,11 @@ public class QuotesController : Controller
var complexityCharge = subtotal * complexityPct;
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)
subtotal = costs.ShopMinimumCharge;
@@ -445,6 +445,14 @@ public static class HelpKnowledgeBase
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
- First-time scan on a new device requires login; browser caches the session after that
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
- If no catalog match is found, the lookup falls back to **AI Lookup** Claude searches the web for product specs and fills in what it finds.
- If a vendor is selected first, the search is scoped to that vendor; if nothing matches it automatically widens to all vendors.
- Click the **camera icon** (next to the Lookup button) to open the **Label Scanner** point the phone camera at a QR code on a powder bag to identify the product automatically. It checks the platform catalog first; if not found, AI analyzes the label image.
- If the scanned product **already exists** in the company's inventory, a modal appears offering to **Add Stock** to the existing item instead enter quantity received and optional updated cost. No duplicate item is created.
**Powder Insights:** [/PowderInsights](/PowderInsights) AI-powered analysis of powder usage trends, efficiency, and cost optimization. Requires at least 10 jobs with powder data; predictive features unlock at 150 jobs.
---
@@ -1148,6 +1156,32 @@ public static class HelpKnowledgeBase
---
## MOBILE APP / ADD TO HOME SCREEN
Powder Coating Logix works in any phone browser but can also be installed as a **home screen app** (Progressive Web App / PWA) for a better experience. Installation is especially important for shop floor workers who use the label scanner or log usage from their phone.
**Why install it:**
- Opens full-screen with no browser address bar feels like a native app
- Camera permission is granted once and remembered permanently (instead of being asked every browser session)
- Faster to launch one tap from the home screen
**iOS (iPhone / iPad) must use Safari:**
iOS only supports home screen installation from Safari. Installing from Chrome or Firefox on iOS creates a regular browser bookmark that does NOT give the standalone or camera-permission benefits.
1. Open the app in **Safari** (not Chrome or Firefox)
2. Tap the **Share button** (box with arrow pointing up) at the bottom of Safari
3. Scroll down and tap **Add to Home Screen**
4. Confirm the name and tap **Add**
The app icon now appears on the home screen and launches in full-screen mode.
**Android Chrome:**
1. Open the app in Chrome
2. Chrome may show an **Install App** banner automatically tap it
3. If no banner: tap the **menu ()** in the top-right **Add to Home Screen** or **Install App**
**Dashboard install banner:** When a mobile user who has not yet installed the app opens the Dashboard, a banner at the top of the page shows device-specific installation instructions. The banner disappears automatically once the app is running as a standalone home screen app, and can also be dismissed manually it will not reappear after dismissal.
---
## BUG REPORTS
**Where:** [Bug Report](/BugReport)
@@ -59,6 +59,23 @@
</div>
</div>
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
<div id="pwa-install-banner" class="row mb-4" style="display:none!important;">
<div class="col-12">
<div class="alert alert-permanent mb-0 d-flex align-items-start gap-3 py-3"
style="background:var(--pcl-paper-2);border:1px solid var(--pcl-rule);border-left:4px solid var(--pcl-ember);">
<div class="flex-shrink-0 mt-1">
<img src="/images/pwa-icon-192.png" alt="" width="36" height="36" style="border-radius:8px;">
</div>
<div class="flex-grow-1">
<div class="fw-semibold mb-1" id="pwa-banner-title">Add to Home Screen</div>
<div class="text-muted small" id="pwa-banner-msg"></div>
</div>
<button type="button" class="btn-close flex-shrink-0" id="pwa-banner-dismiss" aria-label="Dismiss"></button>
</div>
</div>
</div>
@if (guidedActivationBanner?.Show == true)
{
<div class="row mb-4">
@@ -1253,6 +1270,61 @@
</script>
}
<script>
(function () {
var DISMISSED_KEY = 'pcl_pwa_banner_dismissed';
// Already installed as standalone — never show
var isStandalone = window.navigator.standalone === true ||
window.matchMedia('(display-mode: standalone)').matches;
if (isStandalone) return;
// Already dismissed
if (localStorage.getItem(DISMISSED_KEY)) return;
var ua = navigator.userAgent || '';
var isIOS = /iphone|ipad|ipod/i.test(ua);
var isAndroid = /android/i.test(ua);
// Only show on mobile
if (!isIOS && !isAndroid) return;
var banner = document.getElementById('pwa-install-banner');
var msgEl = document.getElementById('pwa-banner-msg');
var titleEl = document.getElementById('pwa-banner-title');
if (isIOS) {
// Detect Safari: has WebKit in UA but NOT Chrome/CriOS/FxiOS
var isSafari = /webkit/i.test(ua) && !/crios|chrome|fxios|opios/i.test(ua);
if (isSafari) {
titleEl.textContent = 'Add to Home Screen';
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
'<strong>Share menu</strong> <span style="font-size:1.1em">&#9650;</span> at the bottom of Safari ' +
'and tap <strong>Add to Home Screen</strong>.';
} else {
titleEl.textContent = 'Open in Safari to Install';
msgEl.innerHTML = 'To add Powder Coating Logix to your home screen, <strong>open this page in Safari</strong> ' +
'(not Chrome or another browser), then tap the <strong>Share menu</strong> ' +
'<span style="font-size:1.1em">&#9650;</span> and choose <strong>Add to Home Screen</strong>. ' +
'This also means the camera only asks for permission once.';
}
} else {
// Android
titleEl.textContent = 'Add to Home Screen';
msgEl.innerHTML = 'Tap the browser <strong>menu&nbsp;(&#8942;)</strong> and choose ' +
'<strong>Add to Home Screen</strong> or <strong>Install App</strong> for the best experience ' +
'and persistent camera access.';
}
banner.style.removeProperty('display');
document.getElementById('pwa-banner-dismiss').addEventListener('click', function () {
localStorage.setItem(DISMISSED_KEY, '1');
banner.style.display = 'none';
});
}());
</script>
@functions {
IHtmlContent PriorityBadge(string priorityCode, string displayName, string colorClass)
{
@@ -379,6 +379,56 @@
</div>
</section>
<section id="mobile-install" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-phone text-primary me-2"></i>Using on Mobile &mdash; Add to Home Screen
</h2>
<p>
Powder Coating Logix works in any phone browser, but installing it as a <strong>home screen app</strong>
gives your shop floor workers a much better experience:
</p>
<ul class="mb-3">
<li class="mb-1">Opens full-screen with no browser chrome — feels like a native app.</li>
<li class="mb-1">The camera (used by the inventory label scanner) only asks for permission <strong>once</strong> after installation, instead of every browser session.</li>
<li class="mb-1">Faster to launch — one tap from the home screen.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
<i class="bi bi-apple flex-shrink-0 mt-1"></i>
<div>
<strong>iPhone / iPad users must use Safari.</strong> Adding to the home screen from Chrome,
Firefox, or other iOS browsers creates a regular bookmark that opens in that browser — not
a standalone app. Only Safari on iOS supports the full home screen install.
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-apple me-1"></i>iOS (iPhone / iPad) — Safari only</h3>
<ol class="mb-4">
<li class="mb-1">Open the app in <strong>Safari</strong>.</li>
<li class="mb-1">Tap the <strong>Share</strong> button <i class="bi bi-box-arrow-up"></i> at the bottom of the screen.</li>
<li class="mb-1">Scroll down and tap <strong>Add to Home Screen</strong>.</li>
<li class="mb-1">Confirm the name and tap <strong>Add</strong>.</li>
<li class="mb-1">The app icon appears on your home screen. Tap it to open in full-screen mode.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-android2 me-1"></i>Android — Chrome</h3>
<ol class="mb-4">
<li class="mb-1">Open the app in <strong>Chrome</strong>.</li>
<li class="mb-1">Chrome may show an <strong>Install App</strong> banner at the bottom automatically — tap it to install.</li>
<li class="mb-1">If no banner appears, tap the <strong>menu (&#8942;)</strong> in the top-right corner and choose <strong>Add to Home Screen</strong> or <strong>Install App</strong>.</li>
<li class="mb-1">Confirm and the icon is added to your home screen.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
When you open the Dashboard on a mobile browser (before installing), a banner at the top
of the page guides you through the install steps for your specific device. Once the app is
installed as a home screen app, the banner disappears automatically.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@@ -393,6 +443,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#roles-and-permissions">Roles and Permissions</a>
<a class="nav-link py-1 px-3 small text-body" href="#your-first-steps">Your First Steps</a>
<a class="nav-link py-1 px-3 small text-body" href="#after-the-wizard">After the Wizard</a>
<a class="nav-link py-1 px-3 small text-body" href="#mobile-install">Using on Mobile</a>
</nav>
</div>
</div>
@@ -73,6 +73,54 @@
</div>
</section>
<section id="catalog-lookup" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-search text-primary me-2"></i>Catalog Lookup &amp; Label Scanner
</h2>
<p>
When adding or editing an inventory item, you don't have to type every field manually.
Two shortcuts let you auto-fill product details in seconds:
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-upc-scan me-1"></i>Smart Catalog Lookup</h3>
<p>
Click the <strong>Lookup</strong> button next to the SKU/Part Number field. Type a color name,
SKU, or part number and the system searches a built-in catalog of thousands of Prismatic Powders
and other manufacturer SKUs. Select a match and the form fills in automatically — item name,
manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specifications.
</p>
<ul class="mb-3">
<li class="mb-1">The catalog only shows products <strong>not already in your inventory</strong>, preventing duplicates. When editing an existing item, its own catalog entry is always shown.</li>
<li class="mb-1">If no catalog match is found, the lookup falls back to <strong>AI Lookup</strong> — Claude searches the web for product specs and fills in whatever it can find.</li>
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
</ul>
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3>
<p>
Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner.
Point your phone or webcam at the QR code printed on a powder bag or manufacturer label.
The scanner reads the code and attempts to identify the product:
</p>
<ol class="mb-3">
<li class="mb-1">If the QR code matches a product in the platform catalog, the form fills in automatically — same as a manual catalog lookup.</li>
<li class="mb-1">If no catalog match is found, the AI analyzes the label image and fills in whatever details it can extract (color name, SKU, manufacturer, finish).</li>
<li class="mb-1">
If the scanned product is <strong>already in your inventory</strong>, a prompt appears to
<strong>Add Stock</strong> to the existing item instead — enter the quantity received and an
optional updated unit cost, then save. No duplicate item is created.
</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-phone me-1 flex-shrink-0 mt-1"></i>
<div>
The label scanner works best on a phone. If you're on iOS, open the page in
<strong>Safari</strong> for reliable camera access. For persistent camera permission
(no prompt each session), <a asp-controller="Help" asp-action="GettingStarted" class="alert-link"
fragment="mobile-install">add the app to your home screen</a>.
</div>
</div>
</section>
<section id="stock-levels" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-boxes text-primary me-2"></i>Stock Levels and Reorder Points
@@ -485,6 +533,7 @@
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-items">Adding Inventory Items</a>
<a class="nav-link py-1 px-3 small text-body" href="#catalog-lookup">Catalog Lookup &amp; Label Scanner</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-levels">Stock Levels and Reorder Points</a>
<a class="nav-link py-1 px-3 small text-body" href="#stock-adjustment">Stock Adjustment</a>
<a class="nav-link py-1 px-3 small text-body" href="#transactions">Transaction Types</a>
@@ -77,7 +77,7 @@
</button>
@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
</button>
}
@@ -74,12 +74,12 @@
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0 d-flex align-items-center">
<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
</button>
@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
</button>
}
@@ -37,6 +37,7 @@
const categorySelect = document.getElementById('field-category');
const coatingSection = document.getElementById('coating-specs-section');
const smartLookupBtn = document.getElementById('smart-lookup-btn');
const scanLabelBtn = document.getElementById('scan-label-btn');
let coatingMap = {};
if (categorySelect && categorySelect.dataset.coatingMap) {
@@ -54,6 +55,7 @@
const show = isCoatingCategory(catId);
if (coatingSection) coatingSection.style.display = show ? '' : 'none';
if (smartLookupBtn) smartLookupBtn.style.display = show ? '' : 'none';
if (scanLabelBtn) scanLabelBtn.style.display = show ? '' : 'none';
const samplePanelSection = document.getElementById('sample-panel-section');
if (samplePanelSection) samplePanelSection.style.display = show ? '' : 'none';
coatingOnlyFields.forEach(id => {
@@ -101,6 +101,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png" />
<!-- First-paint theme: server already stamped data-surface from cookie.
This script only corrects first-visit (no cookie) using OS preference. -->
@@ -2192,5 +2199,10 @@
}, true);
})();
</script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -54,6 +54,8 @@
const params = new URLSearchParams();
if (searchTerm) params.set('q', searchTerm);
if (manufacturer) params.set('vendor', manufacturer);
const currentId = smartBtn.dataset.currentId;
if (currentId) params.set('currentId', currentId);
const resp = await fetch(`${LOOKUP_URL}?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -358,7 +360,7 @@
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<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>
</div>
<div class="modal-body p-0">
@@ -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();
}
@@ -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)));