Commit Graph

142 Commits

Author SHA1 Message Date
spouliot 2d8827ad5c Fix 'Oven (1 batch × 0 min)' display when OvenCycleMinutes is null
Quote.OvenCycleMinutes is nullable — null means 'use company default'.
The Details and DownloadPdf actions were converting null → 0 before passing
it to the view, so the pricing summary showed '× 0 min' even though the
oven cost was correctly calculated from DefaultOvenCycleMinutes.

Fix: resolve null against operatingCosts.DefaultOvenCycleMinutes in both
controller actions (DownloadPdf now loads operating costs for this). Added
a defensive > 0 guard in the view so the minutes clause is omitted entirely
if it still comes through as 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:35:38 -04:00
spouliot 4d10175ce3 Add oven batch cost to AI Quick Quote (1 batch, DefaultOvenCycleMinutes or 50 min)
Previously the quick quote omitted the oven charge entirely, so saved quotes
were under-priced relative to full quotes from the same items.

Pricing: CalculatePricing now calculates ovenBatchCost = (cycleMin/60) × OvenOperatingCostPerHour
using DefaultOvenCycleMinutes (fallback 50 min), then adds it to the total as a quote-level
charge matching how PricingCalculationService handles oven costs.

Save path: SaveQuickQuoteRequest gains OvenBatchCost + OvenCycleMinutes; the Quote record
now stores OvenBatchCost, OvenCycleMinutes, and Total = ItemsSubtotal + OvenBatchCost.

Display: results card shows a sub-line under the estimate price:
"incl. oven 1 batch 50 min: $12.00"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:20:10 -04:00
spouliot 9c4c20e8bd Fix false-positive 'source quote was modified' banner after job conversion
The banner fires when quote.UpdatedAt > job.QuoteSnapshotUpdatedAt. The
snapshot was captured before saving quote.ConvertedToJobId, so the EF
interceptor's automatic UpdatedAt stamp on that save always made the quote
appear newer than the snapshot — triggering the banner on every freshly
converted job even with no actual changes.

Fix: after saving ConvertedToJobId, re-stamp QuoteSnapshotUpdatedAt to the
quote's final UpdatedAt value and save the job once more. The snapshot now
includes the conversion write, so the comparison is equal (not "after") and
the banner stays hidden until the quote genuinely changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:46:39 -04:00
spouliot ecb285657a Fix sandblast-only toggle overflow and $0 AI quote pricing
Overflow: replaced Bootstrap form-check with an explicit flex row so the
two-line label (title + subtitle) never bleeds outside the card boundary.

$0 pricing: when sandblast-only was toggled on an AI item, manualUnitPrice
was cleared and isAiItem set to false. The pricing engine then returned $0
because no prep services with minutes were configured. Fix: preserve the AI
price when toggling sandblast-only, and keep isAiItem=true so the server
routes through the AI-price path (manualUnitPrice) rather than trying to
recalculate from prep labor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:29:03 -04:00
spouliot 0054b7d108 Fix NullReferenceException on Quote Details when quote total is zero
PricingBreakdown was only populated when quote.Total > 0, but the Details
view unconditionally dereferences PricingBreakdown.ItemsSubtotal. Sandblast-
only quotes can legitimately have a $0 total (no powder/oven costs), leaving
PricingBreakdown null and crashing the Details render.

Removed the Total > 0 guard from both Details action overloads — always
populate PricingBreakdown from the stored snapshot fields (all values are 0
for an unpriced or sandblast-only quote, which is safe for display).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:16:24 -04:00
spouliot 63a85b6ce9 Fix sandblast-only oven charge and wizard overflow; fix Jenkins test failures
Sandblast-only oven charge root cause:
renderCoatsList() is called on every step-3 render. When the sandblast-only
toggle was checked it cleared wz.data.coats to [], but renderCoatsList()
then saw an empty list and auto-called addCoatRow(), silently pushing a
Base Coat back into wz.data.coats. The server saw Coats.Any() = true and
included the item in the oven fraction calculation, producing an unexpected
oven batch charge. Fixed by bailing out of renderCoatsList() when
sandblastOnly is active, and added a matching safety net in
buildItemFromWizard() that forces coats:[] when sandblastOnly is true.

Also fixed sandblast-only toggle label overflow: subtitle span changed to
d-block so it wraps beneath the bold label instead of running inline.

Test fixes:
- DepositsController and GiftCertificatesController tests updated with the
  required ICompanyLogoService mock parameter added to the logo fix commit.
- Two PricingCalculationServiceTests updated to include a coat entry on
  each item, matching the service's updated requirement that only items
  with coating layers are considered for oven fraction calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:56:20 -04:00
spouliot 71caa93461 Fix unit test build failures after logo service and pricing changes
DepositsController and GiftCertificatesController gained a required
ICompanyLogoService constructor parameter in the PDF logo fix; their
test factories were not updated and failed to compile on Jenkins.
Added Mock.Of<ICompanyLogoService>() to both factory methods and the
missing using directive to DepositsControllerTests.

PricingCalculationService now only charges oven cost for items that
have explicit coating layers (Coats collection non-empty), because
sandblast/prep-only and labor items do not go in the oven. Two tests
that tested the old "all items count toward oven fraction" logic were
updated to include a single coat entry on each item, which restores
the expected oven fraction math without changing the tested behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:38:18 -04:00
spouliot 2e73cfab54 Miscellaneous UI and pricing updates from prior sessions
- PricingCalculationService: powder coverage and specific gravity math fixes
- Dashboard/Index: minor widget updates
- Jobs/Details, Jobs/Intake: shop floor and intake view improvements
- Quotes/Details: detail view updates
- GiftCertificates/Details: detail view update
- job-photos.js: photo gallery improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:37 -04:00
spouliot 74414c6c71 Add AI overload retry with model fallback and consolidate wizard errors
Anthropic returns overloaded_error (HTTP 529) during high-demand periods.
Previously this failed immediately with a generic error. Now the service
retries Sonnet once after 5s, then falls back to Haiku (a separate
capacity pool) after another 3s before giving up. If all three attempts
are overloaded the user sees a clear "high demand" message rather than a
generic error. Non-overload errors still log at Error level.

Also consolidated AI wizard error display in item-wizard.js: photo upload
failures were using browser alert() while analyze failures used the inline
red alert bar. All errors now go through aiShowError() so they always
appear consistently as the red bar below the Analyze button. Removed the
alert() fallback from aiShowError() itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:27 -04:00
spouliot a8fb56e8ec Fix company logo missing from PDFs and add AI photo save logging
When a tenant uploads a logo it is stored in Azure Blob Storage and
LogoData (the legacy DB byte[]) is cleared. All PDF controllers were
still reading the now-null LogoData, so logos never appeared on any
PDF after upload. Fixed by injecting ICompanyLogoService into all six
affected controllers (Quotes, Invoices, Deposits, GiftCertificates,
PurchaseOrders, CatalogItems) and loading the blob-stored logo first
before falling back to the legacy DB field.

Also added structured logging to the AI photo promotion path in
QuotesController Create/Edit POST so upload failures are visible in
production logs instead of silently swallowed.

Added onclick safety net to the Create and Edit quote submit buttons
so dynamically-injected hidden fields (AiPhotoTempIds) are written
before iOS Safari collects the form data on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:18 -04:00
spouliot ca4fb959aa Add Sales Tax Liability report with PDF and CSV export
Invoice-basis report showing taxable vs non-taxable sales, tax billed
by GL account, monthly trend table/chart, and full invoice detail grid.
Non-taxable invoice rows shaded grey for easy scanning. Quick-preset
date buttons (This Month, Last Month, YTD, Last Year) for common filing
periods. CSV export formatted for accountants and tax-filing software.
Gated behind AllowAccounting() like other financial reports.

- SalesTaxReportDto + 3 supporting DTOs in FinancialReportDtos.cs
- GetSalesTaxReportAsync on IFinancialReportService + implementation
- GenerateSalesTaxReportPdfAsync on IPdfService + QuestPDF implementation
- SalesTax / SalesTaxPdf / SalesTaxCsv actions in ReportsController
- Views/Reports/SalesTax.cshtml with Chart.js monthly trend chart
- Landing page card added to Finance section
- HelpKnowledgeBase and Help/Reports.cshtml updated with full docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:08 -04:00
spouliot 7e0699d5bd Add smart install prompt for supported browsers 2026-05-06 09:05:00 -04:00
spouliot f383339465 Store powder specific gravity and fix coverage math 2026-05-06 08:46:41 -04:00
spouliot 11a1b91be1 Add platform powder catalog management UI with full CRUD and AI lookup
- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 00:27:44 -04:00
spouliot 713efbc2b6 Store Data Protection keys in SQL Server (non-production)
Replaces the local filesystem path (which required IIS app pool write
access to inetpub\wwwroot\DataProtection-Keys) with SQL Server storage
via IDataProtectionKeyContext. Keys now survive deploys and IIS recycles
without any server-side folder permission setup.

Production continues to use Azure Blob Storage unchanged.

- Add Microsoft.AspNetCore.DataProtection.EntityFrameworkCore 8.0.11 to
  Web and Infrastructure projects
- ApplicationDbContext implements IDataProtectionKeyContext
- Migration AddDataProtectionKeys creates DataProtectionKeys table
- Program.cs: non-production path uses PersistKeysToDbContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:08:31 -04:00
spouliot c7a60a1fad Use Azure Blob Storage for Data Protection keys on non-local deployments
When Storage:ConnectionString is configured (dev/staging servers), store
Data Protection keys in Azure Blob Storage (dataprotection-dev/keys.xml)
instead of the local filesystem. Local developer workstations without a
storage connection string continue to use the filesystem fallback.

Fixes UnauthorizedAccessException on the dev IIS server caused by the app
pool identity not having permission to create the DataProtection-Keys
directory after it was wiped during a deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:54:37 -04:00
spouliot c45a6826bd Fix time entry 500 and inventory edit pencil visibility
- Remove parseInt() from time entry worker select — GUIDs were destroyed
  to NaN → sent as null → FindByIdAsync(null) threw 500
- Ledger pencil: also show for Adjustment rows (no PO) so scan-without-job
  entries get an edit button, not just JobUsage rows
- InventoryController: always write JobUsage type for scan-based logs;
  accept Adjustment in edit endpoints; promote Adjustment→JobUsage when
  a job is assigned via edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:46:05 -04:00
spouliot 010d0437c2 Fix grace period tests: set StripeSubscriptionId on test companies
Trial companies (no StripeSubscriptionId) get 0 grace days by design.
The GracePeriod and Expired status tests need a paid subscription to
exercise the 14-day grace window correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:27:38 -04:00
spouliot 20ae11be03 Commit remaining unstaged changes from this session
- Platform settings service: IPlatformSettingsService, PlatformSettingKeys,
  PlatformSettingsService, SubscriptionService, AppConstants,
  SubscriptionExpiryBackgroundService, SubscriptionMiddleware
- JobTimeEntry entity, DTOs, AutoMapper profile (ShopWorker → UserId migration)
- InventoryDtos: SourceTransactionId on PowderUsageLogDto
- InventoryTransactionRepository: include Job.Customer in ledger query
- InventoryAiLookupService: @graph unwrap + HTML price fallback
- ApplicationDbContextModelSnapshot: reflect migration changes
- launchSettings.json, publish profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:20:30 -04:00
spouliot 4c070e7487 Fix SubscriptionServiceTests: add IPlatformSettingsService stub
Tests broke when SubscriptionService gained the platformSettings
constructor parameter in the previous session. Add NullPlatformSettingsService
stub and pass it to all 13 test instantiations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:15:03 -04:00
spouliot 03d3f57f7b Fix time entry workers, powder usage logging, inventory edit, and mojibake
- JobTimeEntry: migrate to UserId/UserDisplayName; make ShopWorkerId nullable
  (migration MigrateTimeEntriesToUserId)
- Log Time modal: populate worker dropdown from Identity users instead of
  ShopWorkers; fix ShopMobile view same issue
- Inventory Ledger: scan-based JobUsage transactions now appear in
  Powder Usage By Job tab (synthesized from InventoryTransaction)
- Inventory Ledger: add Edit button for JobUsage transactions; new
  GetUsageForEdit + EditUsageTransaction endpoints; inventory-ledger.js
- InventoryTransactionRepository: include Job.Customer for ledger queries
- InventoryAiLookupService: handle JSON-LD @graph wrapper (Columbia
  Coatings / WooCommerce+Yoast); add HTML price snippet fallback
- Fix mojibake in 9 views: â†' → →, âœ" → ✓, âš  → ⚠

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:05:37 -04:00
spouliot 7fe8bc81c6 Exclude trial companies from MRR/ARR and revenue trend
Companies with null StripeSubscriptionId are on trial and have no real
subscription income. They were being counted as paying Active/GracePeriod
customers, inflating MRR, ARR, plan distribution, and 12-month trend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 09:10:59 -04:00
spouliot a689b1752d Fix service worker intercepting cross-origin image fetches, breaking CSP connect-src 2026-05-04 23:31:06 -04:00
spouliot 4b845d7a1a Restore linux-x64 RID for SqlClient; xcopy handles wwwroot separately 2026-05-04 23:07:47 -04:00
spouliot 56c8b71706 Use az webapp deploy --type zip --async for WEBSITE_RUN_FROM_PACKAGE=1 mode 2026-05-04 23:01:09 -04:00
spouliot 1e22acf1dc Fix missing wwwroot: remove linux-x64 RID, explicitly xcopy wwwroot after publish 2026-05-04 22:48:39 -04:00
spouliot 676f63e7dc Add wwwroot verification step after publish to diagnose missing static files 2026-05-04 22:43:12 -04:00
spouliot d75e5a02ed Switch to Kudu zip deploy (config-zip) to match manual deploy behavior 2026-05-04 22:34:07 -04:00
spouliot b92a9f78df Restore PATH, linux-x64 publish, and ZipFile deploy lost in merge conflict 2026-05-04 22:19:57 -04:00
spouliot 444748adb7 Merge branch 'dev' 2026-05-04 22:14:55 -04:00
spouliot 4c58d57928 Commit misc scripts, feature specs, SQL deploy scripts, and settings updates 2026-05-04 22:14:25 -04:00
spouliot ee3158b7d5 Hide Scan Label button on page load until coating category is selected 2026-05-04 22:13:19 -04:00
spouliot 87a235ed1d Use ZipFile.CreateFromDirectory for reliable subdirectory inclusion in zip 2026-05-04 22:08:32 -04:00
spouliot be5717ebe2 Remove linux-x64 from solution build; let publish handle RID build 2026-05-04 21:56:41 -04:00
spouliot 1392ddccda Target linux-x64 on build and publish for Azure Linux App Service 2026-05-04 21:54:25 -04:00
spouliot 06c914e65c Add Azure CLI wbin to PATH for Jenkins agent 2026-05-04 21:40:23 -04:00
spouliot 92570bb1f6 Rewrite Jenkinsfile for Windows appdev agent (bat, az CLI, EF direct update) 2026-05-04 21:33:52 -04:00
spouliot e447cdc803 Rewrite Jenkinsfile for Windows appdev agent (bat, az CLI, EF direct update) 2026-05-04 21:29:18 -04:00
spouliot 2885bc1228 Update help docs and AI knowledge base for new features
GettingStarted: add 'Using on Mobile — Add to Home Screen' section covering
iOS Safari install flow, Android Chrome install, and PWA benefits (full-screen,
persistent camera permission). Includes Safari-required warning for iOS.

Inventory: add 'Catalog Lookup & Label Scanner' section covering smart catalog
search (filters out existing inventory, vendor-scoped with fallback), AI Lookup
fallback, camera label scanner (catalog-first then AI), and the add-stock prompt
when a scanned product is already in inventory.

HelpKnowledgeBase: sync both of the above for the AI Help Assistant, plus add
catalog lookup / label scanner detail to the INVENTORY section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:35:31 -04:00
spouliot aa0efe7c6f Add PWA install banner to dashboard
Shows a dismissible banner on mobile only, tailored to three cases:
- iOS + Safari: instructions to tap Share → Add to Home Screen
- iOS + other browser: tells user to open in Safari first (required for standalone)
- Android: instructions to tap menu → Install App / Add to Home Screen

Hidden when already running as standalone PWA, or after user dismisses it
(stored in localStorage so it stays gone). Explains camera permission benefit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:29:21 -04:00
spouliot 7a5fef4741 Fix pwa-icon-192 to use new orange cloud logo
Was still pointing to the old white cloud version; regenerated from pwa-icon-512.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:11:04 -04:00
spouliot 1088af7697 Fix iOS PWA icon: add apple-touch-icon with white background
iOS ignores manifest icons for home screen; requires apple-touch-icon link tag.
Generated 180x180 PNG with white background + padding so the transparent logo
renders correctly instead of filling black.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:06:52 -04:00
spouliot 50b1794799 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>
2026-05-04 08:58:10 -04:00
spouliot e9e37b0bf7 Merge dev: inventory label scanner improvements and AI lookup parity 2026-05-03 20:30:44 -04:00
spouliot 7de65910e3 Extract shared catalog enrichment into EnrichFromCatalogAsync helper
AiLookup and ScanLabel were running separate catalog lookup + auto-contribute
code paths. Both now go through EnrichFromCatalogAsync so any future change
to catalog logic only needs to be made once.

- EnrichFromCatalogAsync: private helper that finds a matching PowderCatalogItem
  by SKU + manufacturer, overwrites AI-inferred spec fields with catalog values
  (catalog is authoritative), fills gaps for URL/price fields with ??=, and
  optionally auto-contributes new entries to the platform catalog. Returns
  (wasInCatalog, addedToCatalog) for callers that show UI badges.
- AiLookup: now calls EnrichFromCatalogAsync then ApplyTdsCureFallbackAsync
  before returning — same enrichment pipeline as ScanLabel.
- ScanLabel: replaced ~50-line inline catalog block with two helper calls.
  Return statement simplified from catalogMatch?.X ?? aiResult.X to just
  aiResult.X since EnrichFromCatalogAsync already merged catalog values in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:27:28 -04:00
spouliot 145da7b5c4 Apply TDS cure fallback and SDS/TDS URL filling to AI Lookup button
Previously these enrichments only ran in the label scanner path (ScanLabel).
The AI Lookup button and AiAugmentFromUrl went through separate code that
returned raw LookupAsync / LookupByUrlAsync results with no TDS fallback
and no SDS/TDS URL propagation to the form.

- InventoryController.ApplyTdsCureFallbackAsync: new private helper that
  checks whether cure temp or cure time is still null after the primary
  lookup, and if a TDS URL was returned calls FetchTdsCureSpecsAsync to
  fill the gap. Mutates the result in place so callers just return it.
- AiLookup: calls ApplyTdsCureFallbackAsync after LookupAsync succeeds.
- AiAugmentFromUrl: calls ApplyTdsCureFallbackAsync after LookupByUrlAsync.
- ScanLabel: replaced the inline TDS fallback block with a call to the
  same helper (merges catalog TDS URL into aiResult first so the helper
  sees the best available URL).
- _InventoryColorFamilyScripts.cshtml: added fillDocUrl() helper that fills
  field-sdsurl / field-tdsurl inputs and shows their open-link buttons when
  the AI lookup returns sdsUrl / tdsUrl. These fields existed in the form
  but were never populated by the AI Lookup button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:19:43 -04:00
spouliot 4182286a31 Fall back to TDS sheet for cure specs when main lookup returns none
After the main AI lookup and catalog search, if CureTemperatureF or
CureTimeMinutes is still null but a TDS URL was found, fetch that page
and ask Claude to extract just the cure schedule.

- IInventoryAiLookupService.FetchTdsCureSpecsAsync: new interface method
- InventoryAiLookupService.FetchTdsCureSpecsAsync: fetches the TDS URL via
  the existing FetchPageAsync pipeline (JSON-LD + doc-link extraction, HTML
  stripping). If the page is a PDF or unreachable, returns Success=false
  silently so no error surfaces in the UI. Otherwise sends a small targeted
  prompt that asks only for cureTemperatureF and cureTimeMinutes and uses
  MaxTokens=256 so the call is fast and cheap.
- InventoryController.ScanLabel: after catalog lookup, computes the resolved
  cure values (catalog preferred over AI result). If either is null and a
  TDS URL exists, calls FetchTdsCureSpecsAsync and merges any newly found
  values back into aiResult before building the JSON response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:14:12 -04:00
spouliot 5e3b0b9ddf Inline add-stock prompt when label scan finds existing inventory item
When a scanned label matches an item already in the tenant's inventory,
the scanner now opens an inline modal asking the user to add stock to the
existing item rather than navigating away or creating a duplicate.

- InventoryController.AddStock: new POST endpoint that creates a Purchase
  transaction, updates QuantityOnHand, and optionally updates UnitCost /
  LastPurchasePrice when a new cost is provided. Returns new balance as JSON.
- InventoryController.ScanLabel: extends the duplicate-detection response
  to include existingQuantityOnHand and existingUnitOfMeasure so the modal
  can display current stock level.
- _LabelScanModal.cshtml: adds #addStockModal with quantity (+ UOM label),
  optional unit cost (pre-filled from scan), optional notes, Add Stock CTA,
  and an escape hatch to create a new entry instead.
- inventory-label-scan.js: when scan returns existingInventoryId the JS
  opens addStockModal instead of a warning banner. Submitting POSTs to
  /Inventory/AddStock and shows the updated balance in a success bar with
  a link to the item. The 'new entry instead' path hides the modal and
  pre-fills the create form with a softer duplicate warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:59:43 -04:00
spouliot 3aeec4ffb2 Warn on label scan when product already exists in tenant inventory
After resolving manufacturer + SKU from the scan, ScanLabel now queries the
tenant's InventoryItems: first by ManufacturerPartNumber exact match (most
precise), then by ColorName + Manufacturer fuzzy match as fallback.

If a match is found, the response includes existingInventoryId and
existingInventoryName. The JS fillFromScan() shows a warning banner with a
direct link to the existing item instead of the normal success message. Form
fields are still pre-filled so the user can proceed to add a new entry (e.g.
a different lot or bag size) if that was the intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:49:13 -04:00
spouliot 28b7b9f86b Fix QR detection (parallel loops), price extraction, and camera pre-warm
QR scanning:
- Run BarcodeDetector and jsQR in parallel — jsQR starts after JSQR_DELAY_MS
  (1.5 s) so both decode simultaneously. BarcodeDetector silently returns empty
  arrays for some QR variants; running jsQR in parallel via a separate rAF loop
  (rafId2) and its own off-screen canvas catches those cases. First decoder to
  find anything calls handleQrResult and sets qrFound = true; the other stops.

Price extraction (two bugs):
- ScanLabel: unitPrice was catalogMatch?.UnitPrice ?? 0m, ignoring aiResult
  .UnitCostPerLb entirely when no catalog match — changed to fall through to AI result
- AppendOffer: only read JSON-LD "price" field; Shopify AggregateOffer uses
  "lowPrice" instead — now checked as fallback so Prismatic Powders prices are found

Camera pre-warm:
- Reverted localStorage approach (caused getUserMedia to fire on every page load,
  showing Chrome's "Ask" prompt immediately before user clicked anything)
- Restored Permissions API gate: preWarmCamera only calls getUserMedia when
  navigator.permissions.query returns 'granted', never risks a page-load prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:45:22 -04:00