Compare commits

..

17 Commits

Author SHA1 Message Date
spouliot 92570bb1f6 Rewrite Jenkinsfile for Windows appdev agent (bat, az CLI, EF direct update) 2026-05-04 21:33:52 -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
spouliot cf36e41139 Label scanner: fix QR detection, blank camera on processing, improve permission flow
QR scanning:
- BarcodeDetector now snapshots to canvas before detect() instead of passing
  live video element — more reliable across Chrome versions
- Uses BarcodeDetector.getSupportedFormats() to detect all formats the browser
  supports rather than hardcoding ['qr_code'], catching data_matrix etc.
- jsQR fallback unchanged (attemptBoth inversion)

Processing overlay:
- Added #scan-processing overlay div to _LabelScanModal with spinner + message
- Camera/scanning UI blanks immediately when QR is found or Scan Text tapped;
  overlay message differs per path ("QR code found..." vs "Reading label with AI...")
- Overlay hides on error (modal stays open); modal close triggers hideProcessing()

Camera permission:
- localStorage flag (scannerCameraGranted) set on every successful getUserMedia
- preWarmCamera() checks flag first, bypassing navigator.permissions.query which
  can return 'prompt' for localhost even when Chrome has 'Allow' internally;
  proactive getUserMedia on page load succeeds silently when permission is granted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:05:41 -04:00
spouliot 97cf6dcbf0 Pre-warm camera stream on page load if permission already granted
Uses Permissions API (non-prompting) to check camera state on load.
If state === 'granted', silently starts the stream so Scan Label opens
instantly with no browser prompt on subsequent page visits. Falls back
gracefully when Permissions API is unavailable or permission is 'prompt'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:48:37 -04:00
spouliot 4b65572f6f Label scanner: native BarcodeDetector + keep stream alive between opens
- Use BarcodeDetector API (Chrome/Edge/Android) as primary QR scanner; it uses
  native OS-level decoding which is far more reliable than jsQR for Prismatic's
  QR codes. Falls back to jsQR (attemptBoth) on Safari/Firefox.
- Keep MediaStream alive between modal opens so the browser does not re-prompt
  for camera permission on each scan within the same page session. Stream is
  released after 2 min of idle (IDLE_RELEASE_MS) or on page unload.
- stopCamera() split into stopQrLoop() (cancel rAF only) and releaseCamera()
  (stop tracks + null srcObject); modal hide now calls stopQrLoop, not releaseCamera.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:45:25 -04:00
spouliot f881b7dd53 Fix label scanner: full field mapping, vision follow-up lookup, SDS/TDS extraction
- LookupByUrlAsync now maps all identity + spec fields from Claude response
  (manufacturer, SKU, colorName, description, sdsUrl, tdsUrl, unitCostPerLb, etc.)
  Previously only augmenting fields were mapped; Columbia QR path left 80% blank
- Vision scan follow-up: after ScanLabelAsync reads label text, automatically run
  LookupAsync using the extracted manufacturer + color/SKU to fill SDS/TDS URLs,
  product page, image, description, and any specs not printed on the bag;
  label values (cure schedule, SKU) remain authoritative and are never overwritten
- SDS/TDS URL extraction: added ExtractDocumentLinks() that scans anchor tags in
  raw HTML before tag-stripping, injects found URLs as [Structured Data] lines so
  Claude can read and echo them back in the JSON response; previously all hrefs
  were lost with the HTML stripping
- Added SdsUrl/TdsUrl to InventoryAiLookupResult, Claude system prompt JSON schema,
  LookupAsync mapping, and ScanLabel response (catalog match ?? aiResult fallback)
- SDS/TDS now also stored on auto-contributed catalog entries
- jsQR inversionAttempts: 'dontInvert' → 'attemptBoth' for better QR detection
  under varying label contrast and lighting conditions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:22:53 -04:00
spouliot 1fc79b77fe Add platform powder catalog, catalog-first lookup, and label scanner
- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:36:25 -04:00
spouliot 3ee08b5e43 Widen SMS Agreements history modal to modal-xl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:03:50 -04:00
spouliot 924748c631 Fix SMS Agreements history modal date column wrapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:50:40 -04:00
spouliot 3ae636d771 Fix SMS Agreements history modal showing undefined values
System.Text.Json serializes PascalCase by default but the modal JS
expected camelCase — added CamelCase naming policy to the serializer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:43:47 -04:00
spouliot 90f333c8f3 Fix SMS Agreements version display and auto-remove stale templates
Fix Razor rendering of TermsVersion — property chains after a literal
character need @() parentheses or Razor misparses the expression.

Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove
stale template rows (no longer canonical, never customised) on next
settings visit, so retired types like JobReadyForPickup SMS disappear
automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:02:25 -04:00
28 changed files with 22024 additions and 167 deletions
Vendored
+30 -130
View File
@@ -1,154 +1,54 @@
pipeline {
agent any
agent { label 'appdev' }
// No triggers — start this pipeline manually from the Jenkins UI only.
environment {
DOTNET_CLI_HOME = '/tmp/dotnet_cli_home'
WEB_PROJECT = 'src/PowderCoating.Web/PowderCoating.Web.csproj'
INFRA_PROJECT = 'src/PowderCoating.Infrastructure/PowderCoating.Infrastructure.csproj'
PUBLISH_DIR = "${WORKSPACE}/publish"
DEPLOY_ZIP = "${WORKSPACE}/deploy_${BUILD_NUMBER}.zip"
MIGRATION_SQL = "${WORKSPACE}/migration_${BUILD_NUMBER}.sql"
options {
disableConcurrentBuilds()
timestamps()
}
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: 'refs/heads/master']],
userRemoteConfigs: scm.userRemoteConfigs
])
echo "Building commit: ${GIT_COMMIT}"
checkout scm
}
}
stage('Build & Test') {
stage('Restore & Build') {
steps {
sh 'dotnet restore'
sh 'dotnet build --no-restore -c Release'
sh '''
dotnet test --no-build -c Release \
--logger "trx;LogFileName=results.trx" \
--results-directory TestResults
'''
bat 'dotnet restore PowderCoatingApp.sln'
bat 'dotnet build PowderCoatingApp.sln -c Release --no-restore'
}
post {
always {
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
}
stage('Run Migrations') {
steps {
bat 'dotnet tool install --global dotnet-ef 2>nul || dotnet tool update --global dotnet-ef 2>nul'
withCredentials([string(credentialsId: 'pcl-prod-sql', variable: 'SQL_CONN')]) {
bat '"%USERPROFILE%\\.dotnet\\tools\\dotnet-ef.exe" database update --project src\\PowderCoating.Infrastructure --startup-project src\\PowderCoating.Web --configuration Release --no-build --context ApplicationDbContext --connection "%SQL_CONN%"'
}
}
}
stage('Publish') {
steps {
sh """
dotnet publish '${WEB_PROJECT}' \
-c Release --no-build \
-o '${PUBLISH_DIR}'
"""
bat 'dotnet publish src\\PowderCoating.Web\\PowderCoating.Web.csproj -c Release --no-build -o publish'
}
}
// Generates an idempotent SQL migration script (no live DB connection required).
// The script checks which migrations have already been applied before running each one.
stage('Generate Migration Script') {
stage('Deploy to Azure') {
steps {
sh """
dotnet ef migrations script \
--idempotent \
--output '${MIGRATION_SQL}' \
--project '${INFRA_PROJECT}' \
--startup-project '${WEB_PROJECT}' \
--context ApplicationDbContext \
--no-build
"""
archiveArtifacts artifacts: "migration_${BUILD_NUMBER}.sql", fingerprint: true
echo "Migration script archived — review it in the Jenkins build artifacts before this pipeline runs next time."
}
}
stage('Apply Migration to Azure SQL') {
steps {
withCredentials([
string(credentialsId: 'PCL_SQL_SERVER', variable: 'SQL_SERVER'),
string(credentialsId: 'PCL_SQL_DATABASE', variable: 'SQL_DATABASE'),
string(credentialsId: 'PCL_SQL_USER', variable: 'SQL_USER'),
string(credentialsId: 'PCL_SQL_PASSWORD', variable: 'SQL_PASSWORD')
]) {
sh '''
echo "Applying migration to ${SQL_SERVER}/${SQL_DATABASE} ..."
/opt/mssql-tools18/bin/sqlcmd \
-S "${SQL_SERVER}" \
-d "${SQL_DATABASE}" \
-U "${SQL_USER}" \
-P "${SQL_PASSWORD}" \
-C \
-b \
-i "${MIGRATION_SQL}"
echo "Migration applied successfully."
'''
}
}
}
stage('Deploy to Azure App Service') {
steps {
withCredentials([
string(credentialsId: 'PCL_AZURE_CLIENT_ID', variable: 'AZ_CLIENT_ID'),
string(credentialsId: 'PCL_AZURE_CLIENT_SECRET', variable: 'AZ_CLIENT_SECRET'),
string(credentialsId: 'PCL_AZURE_TENANT_ID', variable: 'AZ_TENANT_ID'),
string(credentialsId: 'PCL_AZURE_SUBSCRIPTION_ID', variable: 'AZ_SUBSCRIPTION_ID'),
string(credentialsId: 'PCL_AZURE_RESOURCE_GROUP', variable: 'AZ_RG'),
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
]) {
sh '''
az login --service-principal \
--username "$AZ_CLIENT_ID" \
--password "$AZ_CLIENT_SECRET" \
--tenant "$AZ_TENANT_ID" \
--output none
az account set --subscription "$AZ_SUBSCRIPTION_ID"
echo "Packaging deployment artifact ..."
cd "$PUBLISH_DIR"
zip -r "$DEPLOY_ZIP" .
echo "Pushing ZIP to ${AZ_APP} ..."
az webapp deployment source config-zip \
--resource-group "$AZ_RG" \
--name "$AZ_APP" \
--src "$DEPLOY_ZIP"
az logout
echo "Deploy complete."
'''
}
}
}
stage('Smoke Test') {
steps {
withCredentials([
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
]) {
sh '''
APP_URL="https://${AZ_APP}.azurewebsites.net"
echo "Smoke-testing ${APP_URL} ..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 45 --retry 3 --retry-delay 10 \
"${APP_URL}")
echo "HTTP status: ${HTTP_STATUS}"
# 200 = OK, 302 = redirect to login (both are healthy)
if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "302" ]; then
echo "SMOKE TEST FAILED — got HTTP ${HTTP_STATUS}"
exit 1
fi
echo "Smoke test passed."
'''
bat 'powershell -Command "Compress-Archive -Path publish\\* -DestinationPath deploy.zip -Force"'
withCredentials([azureServicePrincipal(
credentialsId: 'azure-pcl',
subscriptionIdVariable: 'AZ_SUB_ID',
clientIdVariable: 'AZ_CLIENT_ID',
clientSecretVariable: 'AZ_CLIENT_SECRET',
tenantIdVariable: 'AZ_TENANT_ID'
)]) {
bat 'az login --service-principal -u "%AZ_CLIENT_ID%" -p "%AZ_CLIENT_SECRET%" --tenant "%AZ_TENANT_ID%" --output none'
bat 'az account set --subscription "%AZ_SUB_ID%"'
bat 'az webapp deploy --resource-group rg-powdercoatinglogix-prod --name linuxpcl --src-path deploy.zip --type zip'
bat 'az logout'
}
}
}
@@ -156,7 +56,7 @@ pipeline {
post {
success {
echo "Production deployment #${BUILD_NUMBER} (${GIT_COMMIT}) completed successfully."
echo "Production deployment #${BUILD_NUMBER} completed successfully."
}
failure {
echo "Pipeline #${BUILD_NUMBER} FAILED — review the stage logs above."
@@ -24,6 +24,8 @@ public class InventoryItemDto
public bool RequiresClearCoat { get; set; }
public string? SpecPageUrl { get; set; }
public string? ImageUrl { get; set; }
public string? SdsUrl { get; set; }
public string? TdsUrl { get; set; }
public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs";
public decimal ReorderPoint { get; set; }
@@ -149,6 +151,14 @@ public class CreateInventoryItemDto
[Display(Name = "Product Image URL")]
public string? ImageUrl { get; set; }
[StringLength(1000, ErrorMessage = "URL cannot exceed 1000 characters")]
[Display(Name = "Safety Data Sheet URL")]
public string? SdsUrl { get; set; }
[StringLength(1000, ErrorMessage = "URL cannot exceed 1000 characters")]
[Display(Name = "Technical Data Sheet URL")]
public string? TdsUrl { get; set; }
[Range(0, 999999999, ErrorMessage = "Quantity on hand must be 0 or greater")]
[Display(Name = "Quantity on Hand")]
public decimal QuantityOnHand { get; set; }
@@ -0,0 +1,49 @@
namespace PowderCoating.Application.DTOs.Inventory;
/// <summary>Result returned by the catalog lookup endpoint to auto-fill inventory fields.</summary>
public class PowderCatalogLookupResult
{
public int Id { get; set; }
public string VendorName { get; set; } = string.Empty;
public string Sku { get; set; } = string.Empty;
public string ColorName { get; set; } = string.Empty;
public string? Description { get; set; }
public decimal UnitPrice { get; set; }
public string? ImageUrl { get; set; }
public string? SdsUrl { get; set; }
public string? TdsUrl { get; set; }
public string? ApplicationGuideUrl { get; set; }
public string? ProductUrl { get; set; }
public bool IsDiscontinued { get; set; }
// Coating specs — populated for scan-contributed entries and AI-augmented lookups
public decimal? CureTemperatureF { get; set; }
public int? CureTimeMinutes { get; set; }
public string? Finish { get; set; }
public string? ColorFamilies { get; set; }
public bool? RequiresClearCoat { get; set; }
public decimal? CoverageSqFtPerLb { get; set; }
public decimal? TransferEfficiency { get; set; }
}
/// <summary>Stats shown on the SuperAdmin Powder Catalog management page.</summary>
public class PowderCatalogStatsDto
{
public int TotalProducts { get; set; }
public int ActiveProducts { get; set; }
public int DiscontinuedProducts { get; set; }
public int VendorCount { get; set; }
public int UserContributedProducts { get; set; }
public DateTime? LastImportedAt { get; set; }
}
/// <summary>Result returned after a catalog import operation.</summary>
public class PowderCatalogImportResult
{
public bool Success { get; set; }
public int Inserted { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Errors { get; set; }
public string? ErrorMessage { get; set; }
}
@@ -26,6 +26,8 @@ public class InventoryAiLookupResult
public string? VendorName { get; set; } // manufacturer/vendor name for dropdown matching
public string? SpecPageUrl { get; set; } // URL of the product page that was fetched
public string? ImageUrl { get; set; } // og:image or first product image found on the page
public string? SdsUrl { get; set; } // Safety Data Sheet URL if found on product page
public string? TdsUrl { get; set; } // Technical Data Sheet URL if found on product page
public string? Reasoning { get; set; } // brief explanation of what was found
}
@@ -40,4 +42,23 @@ public interface IInventoryAiLookupService
string? colorName,
string? colorCode,
string? partNumber);
/// <summary>
/// Fetch cure specs, color families, finish, and clear-coat data from a known product URL.
/// Skips the Serper search step; used after a catalog hit to augment catalog fields.
/// </summary>
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName);
/// <summary>
/// Read a powder label photo and extract manufacturer, color name, SKU, and cure specs
/// using Claude vision. Used by the in-browser label scanner.
/// </summary>
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
/// <summary>
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
/// Called when the main lookup found a TDS URL but cure specs are still missing.
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
/// </summary>
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
}
@@ -27,6 +27,8 @@ public class InventoryItem : BaseEntity
public bool RequiresClearCoat { get; set; } // True if this powder requires a clear coat topcoat
public string? SpecPageUrl { get; set; } // Link to manufacturer's product/spec page
public string? ImageUrl { get; set; } // Product image URL (sourced from og:image on AI lookup)
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
// Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false;
@@ -0,0 +1,71 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-level master list of powder coating products across all vendors.
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
/// Used as a lookup table to auto-fill inventory records without an API call.
/// </summary>
public class PowderCatalogItem
{
public int Id { get; set; }
/// <summary>Vendor name — e.g. "Prismatic Powders", "Columbia Coatings". Unique index with Sku.</summary>
public string VendorName { get; set; } = string.Empty;
/// <summary>Vendor's product SKU. Unique per vendor.</summary>
public string Sku { get; set; } = string.Empty;
public string ColorName { get; set; } = string.Empty;
/// <summary>Cleaned short description — boilerplate stripped at import time.</summary>
public string? Description { get; set; }
/// <summary>Base unit price (lowest quantity tier).</summary>
public decimal UnitPrice { get; set; }
/// <summary>Full price tier JSON for future quantity-break pricing support.</summary>
public string? PriceTiersJson { get; set; }
public string? ImageUrl { get; set; }
public string? SdsUrl { get; set; }
public string? TdsUrl { get; set; }
public string? ApplicationGuideUrl { get; set; }
public string? ProductUrl { get; set; }
// ── Coating specification fields ──────────────────────────────────────
/// <summary>Cure temperature in degrees Fahrenheit.</summary>
public decimal? CureTemperatureF { get; set; }
/// <summary>Cure hold time at cure temperature, in minutes.</summary>
public int? CureTimeMinutes { get; set; }
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
public string? Finish { get; set; }
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
public string? ColorFamilies { get; set; }
/// <summary>Whether this product requires a clear coat to activate its effect.</summary>
public bool? RequiresClearCoat { get; set; }
/// <summary>Theoretical coverage in sq ft per pound. Typical 80120.</summary>
public decimal? CoverageSqFtPerLb { get; set; }
/// <summary>Powder transfer efficiency percentage. Typical 6075%.</summary>
public decimal? TransferEfficiency { get; set; }
// ── Catalog management ────────────────────────────────────────────────
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
public bool IsDiscontinued { get; set; } = false;
/// <summary>True when this record was contributed by a tenant label scan rather than a curated import.</summary>
public bool IsUserContributed { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>Timestamp of the last successful sync run for this record.</summary>
public DateTime? LastSyncedAt { get; set; }
}
@@ -33,6 +33,7 @@ public interface IUnitOfWork : IDisposable
IRepository<QuoteItemPrepService> QuoteItemPrepServices { get; }
IRepository<QuoteChangeHistory> QuoteChangeHistories { get; }
IRepository<InventoryItem> InventoryItems { get; }
IPlainRepository<PowderCatalogItem> PowderCatalog { get; }
IInventoryTransactionRepository InventoryTransactions { get; }
IRepository<Equipment> Equipment { get; }
IRepository<OvenCost> OvenCosts { get; }
@@ -279,6 +279,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; set; }
/// <summary>
/// Platform-level master list of powder coating products across all vendors.
/// Not tenant-scoped — no global query filters applied.
/// </summary>
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -1680,6 +1686,16 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
.IsUnique()
.HasDatabaseName("IX_NotificationTemplates_Company_Type_Channel");
// PowderCatalogItem — platform-level, no tenant filter, unique on (VendorName, Sku)
modelBuilder.Entity<PowderCatalogItem>()
.HasIndex(p => new { p.VendorName, p.Sku })
.IsUnique()
.HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku");
modelBuilder.Entity<PowderCatalogItem>()
.HasIndex(p => p.ColorName)
.HasDatabaseName("IX_PowderCatalogItems_ColorName");
// OvenBatch → Equipment (nullable, legacy — batches are historical records)
modelBuilder.Entity<OvenBatch>()
.HasOne(b => b.Equipment)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SdsUrl",
table: "InventoryItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TdsUrl",
table: "InventoryItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "PowderCatalogItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorName = table.Column<string>(type: "nvarchar(450)", nullable: false),
Sku = table.Column<string>(type: "nvarchar(450)", nullable: false),
ColorName = table.Column<string>(type: "nvarchar(450)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
UnitPrice = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
PriceTiersJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ImageUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
SdsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
TdsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
ApplicationGuideUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
ProductUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDiscontinued = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
LastSyncedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PowderCatalogItems", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3667));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3674));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3675));
migrationBuilder.CreateIndex(
name: "IX_PowderCatalogItems_ColorName",
table: "PowderCatalogItems",
column: "ColorName");
migrationBuilder.CreateIndex(
name: "IX_PowderCatalogItems_Vendor_Sku",
table: "PowderCatalogItems",
columns: new[] { "VendorName", "Sku" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "SdsUrl",
table: "InventoryItems");
migrationBuilder.DropColumn(
name: "TdsUrl",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
}
}
}
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogSpecFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ColorFamilies",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CoverageSqFtPerLb",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CureTemperatureF",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CureTimeMinutes",
table: "PowderCatalogItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Finish",
table: "PowderCatalogItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsUserContributed",
table: "PowderCatalogItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "RequiresClearCoat",
table: "PowderCatalogItems",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "TransferEfficiency",
table: "PowderCatalogItems",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ColorFamilies",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CoverageSqFtPerLb",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureTemperatureF",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "CureTimeMinutes",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "Finish",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "IsUserContributed",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "RequiresClearCoat",
table: "PowderCatalogItems");
migrationBuilder.DropColumn(
name: "TransferEfficiency",
table: "PowderCatalogItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3667));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3674));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 16, 59, 39, 554, DateTimeKind.Utc).AddTicks(3675));
}
}
}
@@ -3307,9 +3307,15 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("SpecPageUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("TdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("TransferEfficiency")
.HasColumnType("decimal(18,2)");
@@ -5740,6 +5746,98 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("PlatformSettings");
});
modelBuilder.Entity("PowderCoating.Core.Entities.PowderCatalogItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ApplicationGuideUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorFamilies")
.HasColumnType("nvarchar(max)");
b.Property<string>("ColorName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<decimal?>("CoverageSqFtPerLb")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CureTemperatureF")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CureTimeMinutes")
.HasColumnType("int");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("Finish")
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDiscontinued")
.HasColumnType("bit");
b.Property<bool>("IsUserContributed")
.HasColumnType("bit");
b.Property<DateTime?>("LastSyncedAt")
.HasColumnType("datetime2");
b.Property<string>("PriceTiersJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProductUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("RequiresClearCoat")
.HasColumnType("bit");
b.Property<string>("SdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TdsUrl")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("TransferEfficiency")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("VendorName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("ColorName")
.HasDatabaseName("IX_PowderCatalogItems_ColorName");
b.HasIndex("VendorName", "Sku")
.IsUnique()
.HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku");
b.ToTable("PowderCatalogItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.PowderUsageLog", b =>
{
b.Property<int>("Id")
@@ -5930,7 +6028,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5941,7 +6039,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5952,7 +6050,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -60,6 +60,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<QuoteItemPrepService>? _quoteItemPrepServices;
private IRepository<QuoteChangeHistory>? _quoteChangeHistories;
private IRepository<InventoryItem>? _inventoryItems;
private IPlainRepository<PowderCatalogItem>? _powderCatalog;
private IInventoryTransactionRepository? _inventoryTransactions;
private IRepository<Equipment>? _equipment;
private IRepository<OvenCost>? _ovenCosts;
@@ -244,6 +245,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<InventoryItem> InventoryItems =>
_inventoryItems ??= new Repository<InventoryItem>(_context);
/// <summary>Platform-level powder catalog — no tenant filter, no soft delete.</summary>
public IPlainRepository<PowderCatalogItem> PowderCatalog =>
_powderCatalog ??= new PlainRepository<PowderCatalogItem>(_context);
/// <summary>Repository for <see cref="InventoryTransaction"/> stock movements; tenant-filtered with soft delete.</summary>
public IInventoryTransactionRepository InventoryTransactions =>
_inventoryTransactions ??= new InventoryTransactionRepository(_context);
@@ -51,6 +51,8 @@ Respond ONLY with a valid JSON object — no markdown, no explanation:
""transferEfficiency"": number or null,
""unitCostPerLb"": number or null,
""vendorName"": ""string or null — the retailer or distributor name if a price was found (not the manufacturer)"",
""sdsUrl"": ""full URL to the Safety Data Sheet (SDS/MSDS) if found in the page content or links — null if not found"",
""tdsUrl"": ""full URL to the Technical Data Sheet (TDS/Spec Sheet) if found in the page content or links — null if not found"",
""reasoning"": ""one sentence: what specific product data was found and how confident you are""
}
@@ -87,6 +89,8 @@ Rules:
* Cerakote: labeled ""Item:"" followed by a short code like F-122 (letter-dash-digits).
* Other brands: look for ""SKU"", ""Item #"", ""Part #"", ""Product Code"", ""Product ID"", ""Code"", or similar labels
- colorCode: RAL code (e.g. RAL 9005), NCS code, or manufacturer's own color code. Return if known — do not infer from the color name alone.
- sdsUrl: look for links or text labeled ""SDS"", ""Safety Data Sheet"", ""MSDS"". If a [Structured Data] SDS URL line is present, use it. Return the full URL or null.
- tdsUrl: look for links or text labeled ""TDS"", ""Technical Data Sheet"", ""Spec Sheet"", ""Data Sheet"". If a [Structured Data] TDS URL line is present, use it. Return the full URL or null.
- If a field cannot be confidently determined, use null.";
public InventoryAiLookupService(
@@ -250,6 +254,8 @@ Rules:
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
result.VendorName = GetString(parsed, "vendorName");
result.SdsUrl = GetString(parsed, "sdsUrl");
result.TdsUrl = GetString(parsed, "tdsUrl");
result.SpecPageUrl = specPageUrl;
result.ImageUrl = pageImageUrl;
result.Reasoning = GetString(parsed, "reasoning");
@@ -267,6 +273,313 @@ Rules:
}
}
/// <summary>
/// Reads a powder label photo using Claude vision and extracts structured product data:
/// manufacturer, color name, SKU, cure temperature, cure time, and finish. Used by the
/// in-browser label scanner so shop staff can point a phone at a bag and auto-fill the
/// inventory form without typing anything.
/// </summary>
public async Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
const string labelPrompt = @"This is a photo of a powder coating product label. Extract every piece of product information visible on the label.
Respond ONLY with a valid JSON object — no markdown, no explanation:
{
""manufacturer"": ""the brand name shown on the label, e.g. 'Prismatic Powders', 'Columbia Coatings'"",
""manufacturerPartNumber"": ""the SKU or part number printed on the label, e.g. 'PPS-1505', 'S5700001'"",
""colorName"": ""the product color name printed on the label"",
""colorCode"": ""RAL or NCS code if printed, otherwise null"",
""description"": ""null — labels don't have descriptions"",
""finish"": ""one of: Gloss, Matte, Satin, Flat, Texture, Wrinkle, Metallic, Pearl, Hammertone, Chrome — infer from the color name or any finish text on label, or null"",
""cureTemperatureF"": ""temperature number in °F from the cure schedule printed on label — convert from °C if needed"",
""cureTimeMinutes"": ""minutes number from the cure schedule printed on label"",
""colorFamilies"": ""comma-separated families from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — infer from color name and bag color"",
""requiresClearCoat"": ""true/false/null based on product name or any text on label"",
""coverageSqFtPerLb"": null,
""transferEfficiency"": null,
""unitCostPerLb"": null,
""vendorName"": ""same as manufacturer for powder labels"",
""reasoning"": ""one sentence: what you read from the label""
}
Rules:
- Read the label text exactly as printed — do not guess or invent SKUs or part numbers.
- cureTemperatureF and cureTimeMinutes are almost always printed on powder labels — look carefully for 'Cure Schedule', 'Cure Time', 'Bake At', or similar text.
- colorFamilies: infer from the color name and the visible powder/bag color in the photo.
- If a field is not on the label and cannot be confidently inferred, use null.";
try
{
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = labelPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new ImageContent
{
Source = new ImageSource
{
MediaType = mediaType,
Data = base64Image
}
},
new TextContent { Text = "Read this powder coating label and return the JSON." }
}
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty;
rawText = rawText.Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var jsonStart = rawText.IndexOf('{');
var jsonEnd = rawText.LastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart)
rawText = rawText[jsonStart..(jsonEnd + 1)];
else
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
{
Success = true,
Manufacturer = GetString(parsed, "manufacturer"),
ManufacturerPartNumber= GetString(parsed, "manufacturerPartNumber"),
ColorName = GetString(parsed, "colorName"),
ColorCode = GetString(parsed, "colorCode"),
Finish = GetString(parsed, "finish"),
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
VendorName = GetString(parsed, "vendorName"),
Reasoning = GetString(parsed, "reasoning"),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during label scan AI call");
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Label scan failed: " + ex.Message };
}
}
/// <summary>
/// Fetches cure specs, color families, finish, and clear-coat data directly from a
/// known product page URL without running a Serper search. Used after a catalog hit
/// to augment the catalog record with fields the catalog table doesn't store.
/// </summary>
public async Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var (pageContent, pageImageUrl) = await FetchPageAsync(url);
var userPrompt = BuildUserPrompt(null, colorName, null, null, new List<string>(), url, pageContent);
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = ClaudeSystemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty;
rawText = rawText.Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var jsonStart = rawText.IndexOf('{');
var jsonEnd = rawText.LastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart)
rawText = rawText[jsonStart..(jsonEnd + 1)];
else
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
{
Success = true,
Manufacturer = GetString(parsed, "manufacturer"),
ManufacturerPartNumber = GetString(parsed, "manufacturerPartNumber"),
ColorName = GetString(parsed, "colorName"),
ColorCode = GetString(parsed, "colorCode"),
Description = GetString(parsed, "description"),
Finish = GetString(parsed, "finish"),
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb"),
VendorName = GetString(parsed, "vendorName"),
SdsUrl = GetString(parsed, "sdsUrl"),
TdsUrl = GetString(parsed, "tdsUrl"),
SpecPageUrl = url,
ImageUrl = pageImageUrl,
Reasoning = GetString(parsed, "reasoning"),
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during AI URL augment for {Url}", url);
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI lookup failed: " + ex.Message };
}
}
/// <summary>
/// Fetches a TDS URL (if it is an HTML page — PDFs are silently skipped) and asks Claude
/// to extract cure temperature and cure time only. Uses the same <see cref="FetchPageAsync"/>
/// pipeline so JSON-LD and document links are still extracted before stripping HTML.
/// Returns <see cref="InventoryAiLookupResult.Success"/> = <c>false</c> without an error
/// message when the page could not be fetched (PDF, redirect loop, etc.) so the caller
/// can merge results without surfacing a user-visible error.
/// </summary>
public async Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false };
try
{
var (pageContent, _) = await FetchPageAsync(tdsUrl);
if (string.IsNullOrWhiteSpace(pageContent))
{
// PDF or unreachable — nothing to send to Claude
_logger.LogInformation("TDS cure lookup skipped (PDF or unreachable): {Url}", tdsUrl);
return new InventoryAiLookupResult { Success = false };
}
// Targeted prompt: we only need cure specs from this document
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
{
""cureTemperatureF"": number or null,
""cureTimeMinutes"": number or null,
""reasoning"": ""one sentence: what cure schedule you found""
}
Rules:
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325400 °F.
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min.
- If neither value can be found in the document, return null for both.";
var sb = new StringBuilder();
sb.AppendLine("Technical Data Sheet content:");
if (!string.IsNullOrWhiteSpace(colorName)) sb.AppendLine($"Product: {colorName}");
sb.AppendLine($"Source: {tdsUrl}");
sb.AppendLine();
sb.AppendLine(pageContent);
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 256,
SystemMessage = curePrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = sb.ToString() } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = (response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty).Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var j = rawText.IndexOf('{');
var k = rawText.LastIndexOf('}');
if (j >= 0 && k > j) rawText = rawText[j..(k + 1)];
else return new InventoryAiLookupResult { Success = false };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
var result = new InventoryAiLookupResult
{
Success = true,
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
Reasoning = GetString(parsed, "reasoning"),
};
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TDS cure spec fetch failed for {Url}", tdsUrl);
return new InventoryAiLookupResult { Success = false };
}
}
// ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary>
@@ -514,6 +827,9 @@ Rules:
// Extract product image from Open Graph / Twitter Card meta tags
var imageUrl = ExtractOgImageUrl(html);
// Extract SDS/TDS document links BEFORE stripping HTML so hrefs aren't lost.
var docLinks = ExtractDocumentLinks(html, url);
// Extract structured data (JSON-LD) BEFORE stripping scripts — it contains
// machine-readable price, SKU, and product info that would otherwise be lost.
var structuredData = ExtractJsonLdData(html);
@@ -535,9 +851,11 @@ Rules:
if (text.Length > maxChars)
text = text[..maxChars] + "…";
// Prepend structured data — Claude should treat this as high-confidence
if (!string.IsNullOrWhiteSpace(structuredData))
text = structuredData + "\n" + text;
// Prepend structured data + document links — Claude treats these as high-confidence
var header = new StringBuilder();
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
if (header.Length > 0) text = header + "\n" + text;
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
text.Length, url, structuredData != null ? "yes" : "no", imageUrl != null ? "yes" : "no");
@@ -579,6 +897,64 @@ Rules:
return null;
}
/// <summary>
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
/// Resolves relative hrefs to absolute URLs using the page's base URL. Stops after
/// finding one SDS and one TDS to avoid returning irrelevant links.
/// </summary>
private static string? ExtractDocumentLinks(string html, string pageUrl)
{
Uri? baseUri = null;
try { baseUri = new Uri(pageUrl); } catch { }
var sb = new StringBuilder();
string? sdsUrl = null, tdsUrl = null;
var matches = System.Text.RegularExpressions.Regex.Matches(
html,
@"<a\s+[^>]*href=[""']([^""'#][^""']*)[""'][^>]*>([\s\S]*?)</a>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in matches)
{
if (sdsUrl != null && tdsUrl != null) break;
var href = m.Groups[1].Value.Trim();
var linkText = System.Text.RegularExpressions.Regex
.Replace(m.Groups[2].Value, @"<[^>]+>", "").Trim();
// Resolve relative hrefs to absolute
string absHref = href;
if (baseUri != null && !href.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
try { absHref = new Uri(baseUri, href).ToString(); } catch { continue; }
}
if (!absHref.StartsWith("http", StringComparison.OrdinalIgnoreCase)) continue;
var hrefL = href.ToLowerInvariant();
var textL = linkText.ToLowerInvariant();
if (sdsUrl == null &&
(textL.Contains("sds") || textL.Contains("safety data") || textL.Contains("msds") ||
hrefL.Contains("sds") || hrefL.Contains("safety") || hrefL.Contains("msds")))
{
sdsUrl = absHref;
sb.AppendLine($"[Structured Data] SDS URL: {absHref}");
}
else if (tdsUrl == null &&
(textL.Contains("tds") || textL.Contains("technical data") || textL.Contains("spec sheet") ||
textL.Contains("data sheet") || hrefL.Contains("/tds") || hrefL.Contains("technical-data") ||
hrefL.Contains("techdata") || hrefL.Contains("datasheet")))
{
tdsUrl = absHref;
sb.AppendLine($"[Structured Data] TDS URL: {absHref}");
}
}
return sb.Length > 0 ? sb.ToString() : null;
}
/// <summary>
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
@@ -659,7 +1035,9 @@ Rules:
/// </summary>
private static void AppendOffer(JsonElement offer, StringBuilder sb)
{
var price = offer.TryGetProperty("price", out var p) ? p.ToString() : null;
// Accept "price" (Offer) or "lowPrice" (AggregateOffer — used by Shopify and others)
var price = offer.TryGetProperty("price", out var p) ? p.ToString() :
offer.TryGetProperty("lowPrice", out var lp) ? lp.ToString() : null;
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
@@ -2557,24 +2557,37 @@ public class CompanySettingsController : Controller
/// company. Called on every visit to the Settings Index and NotificationTemplates pages so new
/// notification types added to <c>SeedData.BuildDefaultNotificationTemplates</c> are automatically
/// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin.
/// Returns the count of newly added templates so the caller can decide whether to reload from the DB.
/// Also removes stale rows (no longer in the canonical list) that have never been customised
/// (UpdatedAt == null), so retired notification types disappear from the UI automatically.
/// Returns the count of changes so the caller can decide whether to reload from the DB.
/// </summary>
private async Task<int> EnsureNotificationTemplatesSeededAsync(
int companyId, List<NotificationTemplate> existing)
{
var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId);
var toAdd = allDefaults
.Where(d => !existing.Any(e =>
e.NotificationType == d.NotificationType && e.Channel == d.Channel))
.ToList();
// Remove rows that are no longer canonical and have never been customised.
var toRemove = existing
.Where(e => !allDefaults.Any(d =>
d.NotificationType == e.NotificationType && d.Channel == e.Channel)
&& e.UpdatedAt == null)
.ToList();
foreach (var t in toAdd)
await _unitOfWork.NotificationTemplates.AddAsync(t);
if (toAdd.Count > 0)
foreach (var t in toRemove)
await _unitOfWork.NotificationTemplates.DeleteAsync(t);
if (toAdd.Count > 0 || toRemove.Count > 0)
await _unitOfWork.CompleteAsync();
return toAdd.Count;
return toAdd.Count + toRemove.Count;
}
/// <summary>
@@ -679,9 +679,407 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." });
var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber);
if (result.Success)
{
await EnrichFromCatalogAsync(result, autoContribute: true);
await ApplyTdsCureFallbackAsync(result, colorName);
}
return Json(result);
}
/// <summary>
/// Augments a catalog fill with cure specs, color families, and finish by fetching the
/// product's known URL and running it through Claude. Skips Serper — the URL is already
/// known from the catalog record so no search step is needed. Gated behind the same
/// AI Inventory Assist subscription flag as AiLookup.
/// </summary>
[HttpPost]
public async Task<IActionResult> AiAugmentFromUrl(
[FromForm] string? productUrl,
[FromForm] string? colorName)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." });
if (string.IsNullOrWhiteSpace(productUrl))
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
return Json(result);
}
/// <summary>
/// Looks up <paramref name="result"/> in the platform powder catalog by SKU + manufacturer.
/// If a match is found, catalog values overwrite Claude-inferred ones for spec fields
/// (catalog is the authoritative source) and fill gaps for URL/price fields.
/// If no match and <paramref name="autoContribute"/> is true, inserts a new catalog entry
/// so future lookups resolve instantly without an API call.
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
/// Mutates <paramref name="result"/> in place.
/// </summary>
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
InventoryAiLookupResult result, bool autoContribute)
{
var sku = result.ManufacturerPartNumber?.Trim();
var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim();
var colorName = result.ColorName?.Trim();
PowderCatalogItem? match = null;
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
{
var skuLower = sku.ToLower();
var mfrLower = manufacturer.ToLower();
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
match = hits.FirstOrDefault();
}
if (match != null)
{
// Catalog is authoritative for spec fields — overwrite AI inference
if (match.Finish != null) result.Finish = match.Finish;
if (match.CureTemperatureF != null) result.CureTemperatureF = match.CureTemperatureF;
if (match.CureTimeMinutes != null) result.CureTimeMinutes = match.CureTimeMinutes;
if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies;
if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat;
if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb;
if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency;
// URL / price fields: fill gaps only — AI may have found something better
result.ImageUrl ??= match.ImageUrl;
result.SpecPageUrl ??= match.ProductUrl;
result.SdsUrl ??= match.SdsUrl;
result.TdsUrl ??= match.TdsUrl;
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
return (true, false);
}
if (!autoContribute
|| string.IsNullOrEmpty(sku)
|| string.IsNullOrEmpty(manufacturer)
|| string.IsNullOrEmpty(colorName))
return (false, false);
// Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly
try
{
var newItem = new PowderCatalogItem
{
VendorName = manufacturer,
Sku = sku,
ColorName = colorName,
CureTemperatureF = result.CureTemperatureF,
CureTimeMinutes = result.CureTimeMinutes,
Finish = result.Finish,
ColorFamilies = result.ColorFamilies,
RequiresClearCoat = result.RequiresClearCoat,
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
TransferEfficiency = result.TransferEfficiency,
ImageUrl = result.ImageUrl,
ProductUrl = result.SpecPageUrl,
SdsUrl = result.SdsUrl,
TdsUrl = result.TdsUrl,
IsUserContributed = true,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.PowderCatalog.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
return (false, true);
}
catch (Exception ex)
{
// Unique constraint violation means another request beat us — not an error
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
return (false, false);
}
}
/// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
/// can always return the result even if the TDS fetch does not help.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success)
{
if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes;
}
}
}
/// <summary>
/// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
/// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
/// catalog, and — when the product is not yet in the catalog and enough data was extracted
/// — inserts it automatically as a user-contributed entry so future scans resolve instantly.
/// </summary>
[HttpPost]
public async Task<IActionResult> ScanLabel(
[FromForm] string? imageBase64,
[FromForm] string? mediaType,
[FromForm] string? qrUrl)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." });
InventoryAiLookupResult aiResult;
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
// Vision path: Claude reads what's printed on the label (limited to visible text)
aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");
// Follow-up web lookup so we get SDS/TDS URLs, product page, image, description,
// and any specs not printed on the label. Label values are kept as-is (authoritative);
// the full lookup only fills fields that are still null.
if (aiResult.Success)
{
var mfr = aiResult.Manufacturer ?? aiResult.VendorName;
if (!string.IsNullOrWhiteSpace(mfr) &&
(!string.IsNullOrWhiteSpace(aiResult.ColorName) || !string.IsNullOrWhiteSpace(aiResult.ManufacturerPartNumber)))
{
var full = await _aiLookupService.LookupAsync(
mfr, aiResult.ColorName, aiResult.ColorCode, aiResult.ManufacturerPartNumber);
if (full.Success)
{
aiResult.Description ??= full.Description;
aiResult.SdsUrl ??= full.SdsUrl;
aiResult.TdsUrl ??= full.TdsUrl;
aiResult.ImageUrl ??= full.ImageUrl;
aiResult.SpecPageUrl ??= full.SpecPageUrl;
aiResult.UnitCostPerLb ??= full.UnitCostPerLb;
aiResult.VendorName ??= full.VendorName;
aiResult.ColorFamilies ??= full.ColorFamilies;
aiResult.Finish ??= full.Finish;
aiResult.CureTemperatureF ??= full.CureTemperatureF;
aiResult.CureTimeMinutes ??= full.CureTimeMinutes;
aiResult.RequiresClearCoat ??= full.RequiresClearCoat;
aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb;
aiResult.TransferEfficiency ??= full.TransferEfficiency;
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
aiResult.ColorName ??= full.ColorName;
aiResult.ColorCode ??= full.ColorCode;
}
}
}
}
else
{
return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
}
if (!aiResult.Success)
return Json(new { success = false, errorMessage = aiResult.ErrorMessage });
var sku = aiResult.ManufacturerPartNumber?.Trim();
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
var colorName = aiResult.ColorName?.Trim();
// Catalog lookup, merge, and auto-contribute — same logic as AiLookup button
var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true);
// TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName);
// Check if this product already exists in the tenant's inventory.
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
// Returns the first active match so the UI can prompt to add stock inline.
int? existingInventoryId = null;
string? existingInventoryName = null;
decimal? existingQuantityOnHand = null;
string? existingUnitOfMeasure = null;
InventoryItem? existingHit = null;
if (!string.IsNullOrEmpty(sku))
{
var skuLower = sku.ToLower();
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
i.ManufacturerPartNumber != null &&
i.ManufacturerPartNumber.ToLower() == skuLower);
existingHit = byPart.FirstOrDefault();
}
if (existingHit == null && !string.IsNullOrEmpty(colorName))
{
var nameLower = colorName.ToLower();
var mfrLower = manufacturer?.ToLower() ?? "";
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
i.Name.ToLower() == nameLower);
existingHit = byName.FirstOrDefault(i =>
string.IsNullOrEmpty(mfrLower) ||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
}
if (existingHit != null)
{
existingInventoryId = existingHit.Id;
existingInventoryName = existingHit.Name;
existingQuantityOnHand = existingHit.QuantityOnHand;
existingUnitOfMeasure = existingHit.UnitOfMeasure;
}
return Json(new
{
success = true,
manufacturer = manufacturer,
manufacturerPartNumber = sku,
colorName = colorName,
description = aiResult.Description,
finish = aiResult.Finish,
cureTemperatureF = aiResult.CureTemperatureF,
cureTimeMinutes = aiResult.CureTimeMinutes,
colorFamilies = aiResult.ColorFamilies,
requiresClearCoat = aiResult.RequiresClearCoat,
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
transferEfficiency = aiResult.TransferEfficiency,
unitPrice = aiResult.UnitCostPerLb ?? 0m,
imageUrl = aiResult.ImageUrl,
productUrl = aiResult.SpecPageUrl,
sdsUrl = aiResult.SdsUrl,
tdsUrl = aiResult.TdsUrl,
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
existingInventoryId = existingInventoryId,
existingInventoryName = existingInventoryName,
existingQuantityOnHand = existingQuantityOnHand,
existingUnitOfMeasure = existingUnitOfMeasure,
reasoning = aiResult.Reasoning,
});
}
/// <summary>
/// Adds stock to an existing inventory item from the label scanner inline prompt.
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddStock(int inventoryItemId, decimal quantity, decimal? unitCost, string? notes)
{
try
{
if (quantity <= 0)
return Json(new { success = false, errorMessage = "Quantity must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return Json(new { success = false, errorMessage = "Item not found." });
var cost = (unitCost.HasValue && unitCost.Value > 0) ? unitCost.Value : item.UnitCost;
item.QuantityOnHand += quantity;
item.LastPurchaseDate = DateTime.UtcNow;
if (unitCost.HasValue && unitCost.Value > 0)
{
item.LastPurchasePrice = unitCost.Value;
item.UnitCost = unitCost.Value;
}
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = quantity,
UnitCost = cost,
TotalCost = quantity * cost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
Notes = !string.IsNullOrWhiteSpace(notes) ? notes.Trim() : "Added via label scan",
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Label scan added {Qty} {UOM} to inventory item {Id} ({Name})",
quantity, item.UnitOfMeasure, item.Id, item.Name);
return Json(new
{
success = true,
newQuantityOnHand = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding stock via label scan to inventory item {ItemId}", inventoryItemId);
return Json(new { success = false, errorMessage = "An error occurred. Please try again." });
}
}
/// <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.
/// </summary>
[HttpGet]
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<object>());
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));
// 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
.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,
vendorName = p.VendorName,
sku = p.Sku,
colorName = p.ColorName,
description = p.Description,
unitPrice = p.UnitPrice,
imageUrl = p.ImageUrl,
sdsUrl = p.SdsUrl,
tdsUrl = p.TdsUrl,
applicationGuideUrl = p.ApplicationGuideUrl,
productUrl = p.ProductUrl,
isDiscontinued = p.IsDiscontinued,
cureTemperatureF = p.CureTemperatureF,
cureTimeMinutes = p.CureTimeMinutes,
finish = p.Finish,
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
transferEfficiency = p.TransferEfficiency
});
return Json(results);
}
/// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted
@@ -0,0 +1,289 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Inventory;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PowderCatalogController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PowderCatalogController> _logger;
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Shows platform-level catalog stats and the import form.
/// </summary>
public async Task<IActionResult> Index()
{
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
var list = all.ToList();
var stats = new PowderCatalogStatsDto
{
TotalProducts = list.Count,
ActiveProducts = list.Count(p => !p.IsDiscontinued),
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
UserContributedProducts = list.Count(p => p.IsUserContributed),
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
};
return View(stats);
}
/// <summary>
/// Accepts a JSON file upload (Prismatic Powders scrape format) and upserts all records
/// into PowderCatalogItems. Strips page-scrape boilerplate from descriptions.
/// Existing records matched by (VendorName, Sku) are updated in-place; new ones are inserted.
/// </summary>
[HttpPost]
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
public async Task<IActionResult> Import(IFormFile file, string vendorName = "Prismatic Powders")
{
if (file == null || file.Length == 0)
{
TempData["Error"] = "Please select a JSON file to import.";
return RedirectToAction(nameof(Index));
}
PowderCatalogImportResult result;
try
{
result = await ImportJsonAsync(file, vendorName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Powder catalog import failed");
TempData["Error"] = $"Import failed: {ex.Message}";
return RedirectToAction(nameof(Index));
}
if (!result.Success)
{
TempData["Error"] = result.ErrorMessage ?? "Import failed.";
}
else
{
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
/// SKU exact matches are ranked first; color name substring matches follow.
/// Returns up to 10 results. Accessible to all authenticated users so the inventory
/// Create/Edit form can call it without a separate policy.
/// </summary>
[AllowAnonymous]
[Authorize]
public async Task<IActionResult> Lookup(string? q)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Json(Array.Empty<PowderCatalogLookupResult>());
var term = q.Trim();
// Exact SKU match first, then color name contains
var all = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term.ToLower() ||
p.ColorName.ToLower().Contains(term.ToLower()) ||
p.Sku.ToLower().Contains(term.ToLower()));
var results = all
.OrderBy(p => p.Sku.ToLower() == term.ToLower() ? 0 : 1)
.ThenBy(p => p.ColorName)
.Take(10)
.Select(p => new PowderCatalogLookupResult
{
Id = p.Id,
VendorName = p.VendorName,
Sku = p.Sku,
ColorName = p.ColorName,
Description = p.Description,
UnitPrice = p.UnitPrice,
ImageUrl = p.ImageUrl,
SdsUrl = p.SdsUrl,
TdsUrl = p.TdsUrl,
ApplicationGuideUrl = p.ApplicationGuideUrl,
ProductUrl = p.ProductUrl,
IsDiscontinued = p.IsDiscontinued
})
.ToList();
return Json(results);
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
{
using var stream = file.OpenReadStream();
using var doc = await JsonDocument.ParseAsync(stream);
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
resultsEl.ValueKind != JsonValueKind.Array)
{
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
}
// Load existing records for this vendor into a lookup dictionary
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
int inserted = 0, updated = 0, skipped = 0, errors = 0;
var toAdd = new List<PowderCatalogItem>();
foreach (var item in resultsEl.EnumerateArray())
{
try
{
var sku = item.GetStringOrNull("sku");
var colorName = item.GetStringOrNull("color_name");
if (string.IsNullOrWhiteSpace(sku) || string.IsNullOrWhiteSpace(colorName))
{
skipped++;
continue;
}
var rawDesc = item.GetStringOrNull("description");
var cleanDesc = StripBoilerplate(rawDesc);
var unitPrice = ExtractBasePrice(item);
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
? tiersEl.GetRawText()
: null;
if (existing.TryGetValue(sku, out var record))
{
record.ColorName = colorName;
record.Description = cleanDesc;
record.UnitPrice = unitPrice;
record.PriceTiersJson = priceTiersJson;
record.ImageUrl = item.GetStringOrNull("sample_image_url");
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
record.ProductUrl = item.GetStringOrNull("product_url");
record.UpdatedAt = now;
record.LastSyncedAt = now;
await _unitOfWork.PowderCatalog.UpdateAsync(record);
updated++;
}
else
{
toAdd.Add(new PowderCatalogItem
{
VendorName = vendorName,
Sku = sku,
ColorName = colorName,
Description = cleanDesc,
UnitPrice = unitPrice,
PriceTiersJson = priceTiersJson,
ImageUrl = item.GetStringOrNull("sample_image_url"),
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
ProductUrl = item.GetStringOrNull("product_url"),
CreatedAt = now,
LastSyncedAt = now
});
inserted++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipping catalog record due to parse error");
errors++;
}
}
if (toAdd.Any())
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
await _unitOfWork.CompleteAsync();
return new PowderCatalogImportResult
{
Success = true,
Inserted = inserted,
Updated = updated,
Skipped = skipped,
Errors = errors
};
}
/// <summary>
/// Strips page-scrape boilerplate that starts at "PRODUCT SUPPORT" or "CUSTOMER SERVICE".
/// Returns a trimmed first-paragraph description suitable for display.
/// </summary>
private static string? StripBoilerplate(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var cutpoints = new[] { "PRODUCT SUPPORT", "CUSTOMER SERVICE", "Q&As", "FAQs" };
var cut = raw.Length;
foreach (var cp in cutpoints)
{
var idx = raw.IndexOf(cp, StringComparison.OrdinalIgnoreCase);
if (idx > 0 && idx < cut)
cut = idx;
}
var cleaned = raw[..cut].Trim();
// Collapse multiple whitespace runs
cleaned = Regex.Replace(cleaned, @"\s{3,}", " ").Trim();
return cleaned.Length > 0 ? cleaned : null;
}
/// <summary>
/// Extracts the base (lowest-quantity) unit price from the price_tiers array.
/// Falls back to 0 if the array is missing or malformed.
/// </summary>
private static decimal ExtractBasePrice(JsonElement item)
{
if (!item.TryGetProperty("price_tiers", out var tiers) || tiers.ValueKind != JsonValueKind.Array)
return 0m;
// Find the tier with the lowest min (base price = smallest quantity break)
decimal? price = null;
int lowestMin = int.MaxValue;
foreach (var tier in tiers.EnumerateArray())
{
if (tier.TryGetProperty("min", out var minEl) && minEl.TryGetInt32(out var min) &&
tier.TryGetProperty("price", out var priceEl) && priceEl.TryGetDecimal(out var p))
{
if (min < lowestMin && min >= 1 && p > 0)
{
lowestMin = min;
price = p;
}
}
}
return price ?? 0m;
}
}
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
internal static class JsonElementExtensions
{
internal static string? GetStringOrNull(this JsonElement el, string property)
{
if (el.TryGetProperty(property, out var val) && val.ValueKind == JsonValueKind.String)
return val.GetString();
return null;
}
}
@@ -72,17 +72,20 @@
<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
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
<button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn">
<i class="bi bi-search me-1"></i>Lookup
</button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
<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">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button>
}
</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Product Details"
data-bs-content="Manufacturer, part number, color name, color code, and finish describe the physical product. AI Lookup can auto-fill these fields from a manufacturer name or part number. Coverage is how many sq ft one pound coats at 1 mil thickness (typical: 30). Transfer Efficiency is what percentage of the powder actually sticks (typical: 6070%). Both values are used to calculate Powder Needed on quotes and jobs.">
data-bs-content="Manufacturer, part number, color name, color code, and finish describe the physical product. Use Lookup to auto-fill these fields — it checks the product catalog first, then falls back to AI. Coverage is how many sq ft one pound coats at 1 mil thickness (typical: 30). Transfer Efficiency is what percentage of the powder actually sticks (typical: 6070%). Both values are used to calculate Powder Needed on quotes and jobs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -123,6 +126,28 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SdsUrl" class="form-label">Safety Data Sheet (SDS)</label>
<div class="input-group">
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" placeholder="https://…" />
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
<span asp-validation-for="SdsUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TdsUrl" class="form-label">Technical Data Sheet (TDS)</label>
<div class="input-group">
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" placeholder="https://…" />
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS">
<i class="bi bi-file-earmark-text"></i>
</a>
</div>
<span asp-validation-for="TdsUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
@@ -374,8 +399,18 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>const inventoryFormIsCreate = true;</script>
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
}
}
@@ -190,6 +190,26 @@
</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.SdsUrl) || !string.IsNullOrEmpty(Model.TdsUrl))
{
<div class="col-12">
<label class="text-muted small mb-1">Data Sheets</label>
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl))
{
<a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a>
}
@if (!string.IsNullOrEmpty(Model.TdsUrl))
{
<a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</a>
}
</div>
</div>
}
}
@if (!string.IsNullOrEmpty(Model.Notes))
{
@@ -74,10 +74,13 @@
<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
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? true))
<button type="button" class="btn btn-sm btn-primary ms-2" id="smart-lookup-btn">
<i class="bi bi-search me-1"></i>Lookup
</button>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<button type="button" class="btn btn-sm btn-outline-primary ms-2" id="ai-lookup-btn">
<i class="bi bi-stars me-1"></i>AI Lookup
<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">
<i class="bi bi-qr-code-scan me-1"></i>Scan Label
</button>
}
</h5>
@@ -125,6 +128,28 @@
</div>
<span asp-validation-for="SpecPageUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SdsUrl" class="form-label">Safety Data Sheet (SDS)</label>
<div class="input-group">
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" placeholder="https://…" />
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS">
<i class="bi bi-file-earmark-pdf"></i>
</a>
</div>
<span asp-validation-for="SdsUrl" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="TdsUrl" class="form-label">Technical Data Sheet (TDS)</label>
<div class="input-group">
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" placeholder="https://…" />
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS">
<i class="bi bi-file-earmark-text"></i>
</a>
</div>
<span asp-validation-for="TdsUrl" class="text-danger"></span>
</div>
<input asp-for="ImageUrl" type="hidden" id="field-imageurl" />
<div class="col-12" id="wrap-imagepreview" style="display:@(Model.ImageUrl != null ? "" : "none");">
<label class="form-label text-muted small">Product Image (from AI Lookup)</label>
@@ -394,7 +419,17 @@
</div>
</div>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<partial name="_LabelScanModal" />
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<partial name="_InventoryColorFamilyScripts" />
<script src="~/js/inventory-catalog-lookup.js"></script>
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
{
<script src="~/js/inventory-label-scan.js"></script>
}
}
@@ -36,7 +36,7 @@
// ── Category → IsCoating map + show/hide coating section ─────────────
const categorySelect = document.getElementById('field-category');
const coatingSection = document.getElementById('coating-specs-section');
const aiBtn = document.getElementById('ai-lookup-btn');
const smartLookupBtn = document.getElementById('smart-lookup-btn');
let coatingMap = {};
if (categorySelect && categorySelect.dataset.coatingMap) {
@@ -53,7 +53,7 @@
function updateCoatingVisibility(catId) {
const show = isCoatingCategory(catId);
if (coatingSection) coatingSection.style.display = show ? '' : 'none';
if (aiBtn) aiBtn.style.display = show ? '' : 'none';
if (smartLookupBtn) smartLookupBtn.style.display = show ? '' : 'none';
const samplePanelSection = document.getElementById('sample-panel-section');
if (samplePanelSection) samplePanelSection.style.display = show ? '' : 'none';
coatingOnlyFields.forEach(id => {
@@ -253,11 +253,8 @@
});
// ── AI Lookup ─────────────────────────────────────────────────────────
const btn = document.getElementById('ai-lookup-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!btn) return;
function showBadMatchBtn() {
if (document.getElementById('ai-bad-match-btn')) return; // already shown
const b = document.createElement('button');
@@ -297,14 +294,15 @@
showStatus('info', '<i class="bi bi-check-circle me-1"></i>Fields cleared. Update any details above and click <em>AI Lookup</em> again.');
}
});
btn.insertAdjacentElement('afterend', b);
const lookupBtn = document.getElementById('smart-lookup-btn');
if (lookupBtn) lookupBtn.insertAdjacentElement('afterend', b);
}
function hideBadMatchBtn() {
document.getElementById('ai-bad-match-btn')?.remove();
}
btn.addEventListener('click', async () => {
async function performAiLookup() {
const manufacturer = document.getElementById('field-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('field-colorname')?.value?.trim() || '';
const colorCode = document.getElementById('field-colorcode')?.value?.trim() || '';
@@ -325,8 +323,6 @@
const effectiveColorName = colorName || itemName;
hideBadMatchBtn();
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
showInfo('Searching for product specifications…', 'AI Lookup');
try {
@@ -454,6 +450,21 @@
aiFilledImage = true;
}
// SDS / TDS document URLs — fill inputs and show open-link buttons
const fillDocUrl = (fieldId, linkId, url, label) => {
if (!url) return;
const el = document.getElementById(fieldId);
const link = document.getElementById(linkId);
if (el && (forceRefill || !el.value.trim())) {
el.value = url;
filled.push(label);
if (!aiFilledFields.includes(fieldId)) aiFilledFields.push(fieldId);
}
if (link) { link.href = url; link.classList.remove('d-none'); }
};
fillDocUrl('field-sdsurl', 'field-sdsurl-link', data.sdsUrl, 'SDS');
fillDocUrl('field-tdsurl', 'field-tdsurl-link', data.tdsUrl, 'TDS');
// Build a persistent "needs more info" tip if key identity fields are still unknown
const missingHints = [];
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())
@@ -487,11 +498,12 @@
showError('Request failed: ' + err.message, 'AI Lookup Error');
showStatus('danger', 'Request failed: ' + err.message);
} finally {
forceRefill = false; // reset after each run
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars me-1"></i>AI Lookup';
forceRefill = false; // reset after each run
}
});
}
// Expose so inventory-catalog-lookup.js can fall back to AI when catalog misses
window._runInventoryAiLookup = performAiLookup;
function debugPanel(data) {
const json = JSON.stringify(data, null, 2);
@@ -0,0 +1,114 @@
<!-- Add-stock modal: shown when label scan matches an existing inventory item -->
<div class="modal fade" id="addStockModal" tabindex="-1" aria-labelledby="addStockModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:420px;">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="addStockModalLabel">
<i class="bi bi-box-seam me-2 text-success"></i>Already in Inventory
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pb-2">
<p class="mb-1">
<strong id="add-stock-item-name" class="text-body"></strong> is already in your inventory.
</p>
<p class="text-muted small mb-3">
Current stock: <strong id="add-stock-current-qty"></strong>
</p>
<div class="mb-3">
<label class="form-label fw-semibold small">Quantity to Add <span class="text-danger">*</span></label>
<div class="input-group input-group-sm">
<input type="number" id="add-stock-qty" class="form-control" min="0.01" step="0.01" placeholder="e.g. 5">
<span class="input-group-text" id="add-stock-uom-label">lbs</span>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small">Unit Cost <span class="text-muted fw-normal">(optional — updates item cost)</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" id="add-stock-cost" class="form-control" min="0" step="0.01" placeholder="Leave blank to keep current">
</div>
</div>
<div class="mb-2">
<label class="form-label fw-semibold small">Notes <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" id="add-stock-notes" class="form-control form-control-sm" placeholder="e.g. New bag received">
</div>
<div id="add-stock-status" class="d-none small mt-2"></div>
</div>
<div class="modal-footer flex-column align-items-stretch gap-2 py-2">
<button id="add-stock-confirm-btn" type="button" class="btn btn-success">
<i class="bi bi-plus-circle me-1"></i>Add Stock
</button>
<button id="add-stock-new-btn" type="button" class="btn btn-link btn-sm text-muted">
Add as a new entry instead (e.g. different lot)
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="labelScanModal" tabindex="-1" aria-labelledby="labelScanModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:480px;">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="labelScanModalLabel">
<i class="bi bi-qr-code-scan me-2 text-primary"></i>Scan Powder Label
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0 position-relative bg-black" style="min-height:300px;">
<!-- Live camera feed -->
<video id="scan-video" autoplay playsinline muted
style="width:100%;display:block;max-height:400px;object-fit:cover;"></video>
<!-- Hidden canvas used for QR analysis and frame capture -->
<canvas id="scan-canvas" style="display:none;"></canvas>
<!-- Targeting overlay: darkened edges with a bright center window -->
<div style="position:absolute;inset:0;pointer-events:none;">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<mask id="scan-mask">
<rect width="100%" height="100%" fill="white"/>
<rect x="15%" y="20%" width="70%" height="60%" rx="8" fill="black"/>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.45)" mask="url(#scan-mask)"/>
<!-- Corner brackets -->
<g stroke="#fff" stroke-width="3" fill="none" opacity="0.9">
<path d="M 15% 28% L 15% 20% L 23% 20%"/>
<path d="M 77% 20% L 85% 20% L 85% 28%"/>
<path d="M 85% 72% L 85% 80% L 77% 80%"/>
<path d="M 23% 80% L 15% 80% L 15% 72%"/>
</g>
</svg>
</div>
<!-- Processing overlay: shown while the server lookup is running -->
<div id="scan-processing" style="display:none;position:absolute;inset:0;z-index:10;background:rgba(0,0,0,0.88);align-items:center;justify-content:center;flex-direction:column;color:#fff;text-align:center;padding:1.5rem;">
<div class="spinner-border text-light mb-3" style="width:2.5rem;height:2.5rem;"></div>
<div id="scan-processing-msg" class="fw-medium fs-6">Looking up product…</div>
<div class="text-white-50 small mt-1">This may take a few seconds</div>
</div>
<!-- Status inside the modal -->
<div id="scan-modal-status" class="alert alert-info py-2 small mb-0 mt-2 d-none mx-2 mb-2"
style="position:absolute;bottom:0;left:0;right:0;margin:8px !important;"></div>
</div>
<div class="modal-footer flex-column align-items-stretch py-2 gap-2">
<div class="text-muted small text-center">
<i class="bi bi-magic me-1"></i>QR codes are detected automatically.
</div>
<div id="scan-shutter-wrap" class="d-none">
<div class="text-muted small text-center mb-2">No QR code? Tap to read the label text with AI.</div>
<button id="scan-shutter-btn" type="button" class="btn btn-secondary w-100">
<i class="bi bi-camera me-1"></i>Scan Label Text
</button>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,158 @@
@model PowderCoating.Application.DTOs.Inventory.PowderCatalogStatsDto
@{
ViewData["Title"] = "Powder Catalog";
ViewData["PageIcon"] = "bi-palette2";
Layout = "_Layout";
}
<div class="container-fluid">
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show mb-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Stats cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-primary bg-opacity-10">
<i class="bi bi-collection fs-4 text-primary"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalProducts.ToString("N0")</div>
<div class="text-muted small">Total Products</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-success bg-opacity-10">
<i class="bi bi-check-circle fs-4 text-success"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.ActiveProducts.ToString("N0")</div>
<div class="text-muted small">Active</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-warning bg-opacity-10">
<i class="bi bi-slash-circle fs-4 text-warning"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.DiscontinuedProducts.ToString("N0")</div>
<div class="text-muted small">Discontinued</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-info bg-opacity-10">
<i class="bi bi-building fs-4 text-info"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.VendorCount</div>
<div class="text-muted small">
@(Model.VendorCount == 1 ? "Vendor" : "Vendors")
@if (Model.LastImportedAt.HasValue)
{
<br /><span class="text-muted" style="font-size:.75rem;">Last sync @Model.LastImportedAt.Value.ToString("MMM d, yyyy")</span>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-3 p-3 bg-purple bg-opacity-10" style="background:rgba(111,66,193,.1)">
<i class="bi bi-qr-code-scan fs-4" style="color:#6f42c1;"></i>
</div>
<div>
<div class="fs-3 fw-bold">@Model.UserContributedProducts.ToString("N0")</div>
<div class="text-muted small">Tenant Contributed</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Import card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2 text-primary"></i>Import Catalog Data</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload a Prismatic Powders scrape JSON file (the <code>prismatic_powders.json</code> format with
a top-level <code>results</code> array). Existing SKUs are updated in-place; new ones are inserted.
Discontinued products remain in the catalog flagged as <code>IsDiscontinued</code>.
</p>
<form asp-action="Import" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-medium">Vendor Name</label>
<input type="text" name="vendorName" value="Prismatic Powders" class="form-control" required />
<div class="form-text">Must match exactly — used as the upsert key alongside SKU.</div>
</div>
<div class="mb-3">
<label class="form-label fw-medium">JSON File <span class="text-danger">*</span></label>
<input type="file" name="file" accept=".json" class="form-control" required />
<div class="form-text">Max 50 MB. Must be the scraped format with <code>results[]</code> array.</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-import">
<i class="bi bi-upload me-2"></i>Import
</button>
</form>
</div>
</div>
</div>
<!-- Info / how it works card -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>How It Works</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0" style="line-height:2;">
<li><i class="bi bi-check2 text-success me-2"></i><strong>Platform-level:</strong> One shared catalog, no per-tenant copies.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Catalog-first lookup:</strong> When a tenant adds inventory, the form searches here before calling the AI API.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Auto-fill:</strong> Selecting a result fills color name, manufacturer, part number, unit cost, SDS/TDS links, and product image.</li>
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued:</strong> Flagged <code>IsDiscontinued = true</code> — never hidden, always available for historical lookups.</li>
<li><i class="bi bi-clock text-muted me-2"></i><strong>Phase 2:</strong> Monthly price sync + push to tenant inventory items.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', function () {
var btn = document.getElementById('btn-import');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing…';
});
</script>
@@ -1308,6 +1308,10 @@
<i class="bi bi-database-fill-gear"></i>
<span>Seed Data</span>
</a>
<a asp-controller="PowderCatalog" asp-action="Index" class="nav-link">
<i class="bi bi-palette2"></i>
<span>Powder Catalog</span>
</a>
<a asp-controller="ManufacturerLookupPatterns" asp-action="Index" class="nav-link">
<i class="bi bi-link-45deg"></i>
<span>Manufacturer Lookup Patterns</span>
@@ -155,12 +155,12 @@
<td>
@if (row.CurrentAgreement != null)
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>v@row.CurrentAgreement.TermsVersion</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>v@(row.CurrentAgreement.TermsVersion)</span>
}
else if (row.LatestAgreement != null)
{
<span class="badge bg-warning text-dark" title="Accepted v@row.LatestAgreement.TermsVersion — current is v@currentVersion">
<i class="bi bi-exclamation-triangle me-1"></i>Stale (v@row.LatestAgreement.TermsVersion)
<span class="badge bg-warning text-dark" title="Accepted v@(row.LatestAgreement.TermsVersion) — current is v@currentVersion">
<i class="bi bi-exclamation-triangle me-1"></i>Stale (v@(row.LatestAgreement.TermsVersion))
</span>
}
else
@@ -222,7 +222,7 @@
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
IpAddress = a.IpAddress ?? "—",
UserAgent = a.UserAgent ?? "—"
}))">
}), new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase })">
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
</button>
}
@@ -249,7 +249,7 @@
<!-- History Modal -->
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="historyModalLabel">
@@ -264,7 +264,7 @@
<tr>
<th>Terms Version</th>
<th>Accepted By</th>
<th>Accepted At</th>
<th style="white-space:nowrap;">Accepted At</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
@@ -292,7 +292,7 @@
<tr>
<td><span class="badge bg-primary">v${r.termsVersion}</span></td>
<td>${r.agreedByUserName}</td>
<td>${r.agreedAt}</td>
<td style="white-space:nowrap;">${r.agreedAt}</td>
<td><code class="small">${r.ipAddress}</code></td>
<td><small class="text-muted text-truncate d-block" style="max-width:260px;" title="${r.userAgent}">${r.userAgent}</small></td>
</tr>`).join('');
@@ -0,0 +1,416 @@
/**
* Unified Lookup button for the Inventory Create/Edit forms.
*
* Flow:
* 1. User fills in Manufacturer + Color Name (and/or Part Number) in the existing fields.
* 2. Clicks "Lookup".
* 3. This script searches the platform PowderCatalogItems table first (no API cost).
* - 1 exact/best match auto-fills fields immediately (same UX as AI Lookup).
* - Multiple matches Bootstrap modal lets user pick the right one.
* - No match falls through to window._runInventoryAiLookup() if AI is enabled.
* 4. After a catalog hit, if AI is enabled, augments with cure data from the product URL.
*
* The AI-only button (#ai-lookup-btn) is still wired by _InventoryColorFamilyScripts.cshtml
* and can be used to skip the catalog and go straight to AI.
*/
(function () {
'use strict';
const LOOKUP_URL = '/Inventory/CatalogLookup';
const AUGMENT_URL = '/Inventory/AiAugmentFromUrl';
const smartBtn = document.getElementById('smart-lookup-btn');
const statusEl = document.getElementById('ai-lookup-status'); // shared with AI lookup
if (!smartBtn) return;
// Snapshot of field values set by the catalog fill so we can clear them all
// when the user starts typing a new color name. null when no catalog fill is active.
let catalogSnapshot = null;
// ── Button click ──────────────────────────────────────────────────────────
smartBtn.addEventListener('click', async function () {
const manufacturer = document.getElementById('field-manufacturer')?.value?.trim() || '';
const colorName = document.getElementById('field-colorname')?.value?.trim() || '';
const itemName = document.getElementById('field-name')?.value?.trim() || '';
// Don't use part number as the search term if the catalog previously filled it —
// the snapshot tracks catalog-owned field values.
const partNumberEl = document.getElementById('field-partnumber');
const partNumber = (catalogSnapshot?.['field-partnumber'] == null && partNumberEl?.value?.trim()) || '';
// Color name takes priority — it's what the user types when they want a specific powder.
const searchTerm = colorName || itemName || partNumber;
if (!searchTerm && !manufacturer) {
showStatus('warning', 'Fill in at least a Color Name or Part Number, then click Lookup.');
return;
}
setLoading(true);
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Searching catalog…');
try {
const params = new URLSearchParams();
if (searchTerm) params.set('q', searchTerm);
if (manufacturer) params.set('vendor', manufacturer);
const resp = await fetch(`${LOOKUP_URL}?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const items = await resp.json();
if (items.length === 0) {
// No catalog match — fall back to AI if available
hideStatus();
if (typeof window._runInventoryAiLookup === 'function') {
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching with AI…');
await window._runInventoryAiLookup();
} else {
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
}
return;
}
if (items.length === 1) {
await fillFields(items[0]);
return;
}
// Multiple matches — let the user pick via modal
hideStatus();
showPickerModal(items);
} catch (err) {
showStatus('danger', 'Lookup failed: ' + err.message);
} finally {
setLoading(false);
}
});
// ── Fill fields from a catalog result ────────────────────────────────────
async function fillFields(item) {
catalogSnapshot = {};
const filled = [];
function setIf(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim()) {
el.value = String(value).trim();
catalogSnapshot[id] = String(value).trim();
filled.push(label);
}
}
setIf('field-manufacturer', item.vendorName, 'Manufacturer');
setIf('field-partnumber', item.sku, 'Part Number');
setIf('field-colorname', item.colorName, 'Color Name');
// Name field (coating items use color name as name)
const nameEl = document.getElementById('field-name');
if (nameEl && !nameEl.value.trim() && item.colorName) {
nameEl.value = item.colorName;
catalogSnapshot['field-name'] = item.colorName;
filled.push('Name');
}
// Description — only fill if currently empty
const descEl = document.getElementById('field-description');
if (descEl && !descEl.value.trim() && item.description) {
descEl.value = item.description;
catalogSnapshot['field-description'] = item.description;
filled.push('Description');
}
// Unit cost — only fill if currently zero/empty
const costEl = document.getElementById('field-unitcost');
if (costEl && item.unitPrice > 0 && (parseFloat(costEl.value) || 0) === 0) {
costEl.value = item.unitPrice;
catalogSnapshot['field-unitcost'] = String(item.unitPrice);
filled.push('Unit Cost');
}
// Coating specs — populated for scan-contributed entries; skip if already filled
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
setIfEmpty('field-finish', item.finish, 'Finish');
setIfEmpty('field-curetemp', item.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', item.cureTimeMinutes, 'Cure Time');
setIfEmpty('field-coverage', item.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', item.transferEfficiency,'Transfer Efficiency');
if (item.requiresClearCoat != null) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = item.requiresClearCoat; filled.push('Clear Coat'); }
}
if (item.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = item.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
filled.push('Color Families');
}
}
// Product URL + open-link button
setIf('field-specpageurl', item.productUrl, 'Product URL');
syncLinkButton('field-specpageurl', 'field-specpageurl-link', item.productUrl);
// SDS / TDS
setIf('field-sdsurl', item.sdsUrl, 'SDS');
syncLinkButton('field-sdsurl', 'field-sdsurl-link', item.sdsUrl);
setIf('field-tdsurl', item.tdsUrl, 'TDS');
syncLinkButton('field-tdsurl', 'field-tdsurl-link', item.tdsUrl);
// Image
if (item.imageUrl) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) { imgInput.value = item.imageUrl; catalogSnapshot['field-imageurl'] = item.imageUrl; }
if (imgEl) imgEl.src = item.imageUrl;
if (imgWrap) imgWrap.style.display = '';
filled.push('Image');
}
// Vendor dropdown — match by name
const vendorSel = document.getElementById('field-vendor');
if (vendorSel && !vendorSel.value && item.vendorName) {
const needle = item.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
const discontinuedNote = item.isDiscontinued
? ' <span class="badge bg-warning text-dark ms-1">Discontinued</span>' : '';
if (filled.length > 0) {
showStatus('success', `Filled from catalog: ${filled.join(', ')}.${discontinuedNote}`);
} else {
showStatus('info', `Found in catalog but no empty fields to fill.${discontinuedNote}`);
}
// Augment with AI if enabled and we have a product URL with cure data to fetch
if (item.productUrl && typeof window._runInventoryAiLookup === 'function') {
await augmentFromUrl(item.productUrl, item.colorName, filled, discontinuedNote);
}
}
// ── AI augmentation from product URL ────────────────────────────────────
async function augmentFromUrl(productUrl, colorName, alreadyFilled, discontinuedNote) {
smartBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Augmenting with AI…';
showStatus('info',
'<span class="spinner-border spinner-border-sm me-1"></span>' +
'Filled from catalog — fetching cure specs with AI…');
try {
const fd = new FormData();
fd.append('productUrl', productUrl);
if (colorName) fd.append('colorName', colorName);
const resp = await fetch(AUGMENT_URL, { method: 'POST', body: fd });
if (!resp.ok) {
// Restore the plain catalog success message and bail
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
return;
}
const data = await resp.json();
if (!data.success) {
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
return;
}
const augFilled = [];
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
augFilled.push(label);
}
}
setIfEmpty('field-finish', data.finish, 'Finish');
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
if (data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = data.requiresClearCoat; augFilled.push('Clear Coat'); }
}
// Color families — only set if not already chosen
if (data.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
augFilled.push('Color Families');
}
}
// Image — only if catalog didn't provide one
if (data.imageUrl && !document.getElementById('field-imageurl')?.value?.trim()) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) imgInput.value = data.imageUrl;
if (imgEl) imgEl.src = data.imageUrl;
if (imgWrap) imgWrap.style.display = '';
augFilled.push('Image');
}
const allFilled = [...alreadyFilled, ...augFilled];
if (augFilled.length > 0) {
showStatus('success', `Filled from catalog + AI: ${allFilled.join(', ')}.${discontinuedNote}`);
} else {
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
}
} catch (err) {
// AI augment is optional — restore the catalog success message
showStatus('success', `Filled from catalog: ${alreadyFilled.join(', ')}.${discontinuedNote}`);
} finally {
// Always restore button label — the outer click handler manages disabled state
// for single-match path, but the modal picker path needs this finally to reset it.
smartBtn.innerHTML = '<i class="bi bi-search me-1"></i>Lookup';
}
}
// ── Clear all catalog-filled fields ─────────────────────────────────────
function clearCatalogFill() {
if (!catalogSnapshot) return;
Object.keys(catalogSnapshot).forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
// Clear image preview if catalog filled the image
if (catalogSnapshot['field-imageurl']) {
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap= document.getElementById('wrap-imagepreview');
if (imgEl) imgEl.src = '';
if (imgWrap) imgWrap.style.display = 'none';
}
// Clear color families if they were set by augment
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput) {
hiddenInput.value = '';
document.querySelectorAll('.color-family-chip').forEach(c => c.classList.remove('active'));
}
catalogSnapshot = null;
hideStatus();
}
// When user starts typing a new color name, clear all catalog-filled fields so the
// next search uses the fresh value rather than catalog-owned data.
const colorNameEl = document.getElementById('field-colorname');
if (colorNameEl) {
colorNameEl.addEventListener('input', function () {
if (catalogSnapshot && colorNameEl.value !== (catalogSnapshot['field-colorname'] || '')) {
clearCatalogFill();
}
});
}
// ── Modal picker for multiple results ────────────────────────────────────
function showPickerModal(items) {
// Remove any stale instance
document.getElementById('catalogPickerModal')?.remove();
const rows = items.map((item, i) => {
const img = item.imageUrl
? `<img src="${esc(item.imageUrl)}" style="width:36px;height:36px;object-fit:contain;border-radius:4px;" alt="">`
: `<div style="width:36px;height:36px;background:var(--bs-secondary-bg);border-radius:4px;"></div>`;
const disc = item.isDiscontinued
? `<span class="badge bg-warning text-dark ms-1" style="font-size:.65rem;">Discontinued</span>` : '';
return `
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2 catalog-pick-row" data-idx="${i}">
${img}
<div class="flex-grow-1 text-start">
<div class="fw-medium">${esc(item.colorName)} ${disc}</div>
<div class="text-muted small">${esc(item.vendorName)} &middot; ${esc(item.sku)} &middot; $${item.unitPrice.toFixed(2)}/lb</div>
</div>
</button>`;
}).join('');
const modal = document.createElement('div');
modal.innerHTML = `
<div class="modal fade" id="catalogPickerModal" tabindex="-1">
<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>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="list-group list-group-flush">${rows}</div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(document.getElementById('catalogPickerModal'));
document.querySelectorAll('.catalog-pick-row').forEach(function (btn) {
btn.addEventListener('click', function () {
const idx = parseInt(this.dataset.idx, 10);
bsModal.hide();
fillFields(items[idx]);
});
});
bsModal.show();
}
// ── Helpers ───────────────────────────────────────────────────────────────
function syncLinkButton(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
function setLoading(on) {
smartBtn.disabled = on;
smartBtn.innerHTML = on
? '<span class="spinner-border spinner-border-sm me-1"></span>Looking up…'
: '<i class="bi bi-search me-1"></i>Lookup';
}
function showStatus(type, msg) {
if (!statusEl) return;
statusEl.className = `alert alert-${type} py-2 small mb-3 alert-permanent`;
statusEl.innerHTML = msg;
statusEl.classList.remove('d-none');
}
function hideStatus() {
if (statusEl) statusEl.classList.add('d-none');
}
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();
@@ -0,0 +1,580 @@
/**
* In-browser powder label scanner for the Inventory Create/Edit forms.
*
* QR scanning strategy (parallel for maximum compatibility):
* 1. BarcodeDetector (Chrome/Edge/Android) starts immediately canvas snapshot approach.
* 2. jsQR starts in parallel after JSQR_DELAY_MS so both libraries run simultaneously.
* First one to decode anything wins. Running both covers cases where BarcodeDetector
* silently returns empty arrays for certain QR variants (e.g. Prismatic Powders).
*
* Camera permission:
* Pre-warm only fires when navigator.permissions.query returns 'granted' so we never
* show a browser prompt on page load if Chrome has the site at "Ask", the prompt
* appears only when the user explicitly clicks Scan Label (once per page session after
* that, because the stream stays alive between modal opens).
*/
(function () {
'use strict';
const SCAN_URL = '/Inventory/ScanLabel';
const JSQR_CDN = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
const JSQR_DELAY_MS = 1500; // start jsQR fallback this long after BarcodeDetector
const IDLE_RELEASE_MS = 2 * 60 * 1000;
const scanBtn = document.getElementById('scan-label-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!scanBtn) return;
let stream = null;
let rafId = null; // BarcodeDetector rAF
let rafId2 = null; // jsQR rAF (parallel fallback)
let jsqrTimer = null; // timer that starts jsQR loop
let qrFound = false;
let shutterTimer = null;
let idleTimer = null;
// ── Modal elements ────────────────────────────────────────────────────
const modalEl = document.getElementById('labelScanModal');
const bsModal = modalEl ? new bootstrap.Modal(modalEl) : null;
const videoEl = document.getElementById('scan-video');
const canvasEl = document.getElementById('scan-canvas');
const scanStatusEl = document.getElementById('scan-modal-status');
const shutterBtn = document.getElementById('scan-shutter-btn');
const shutterWrap = document.getElementById('scan-shutter-wrap');
const processingEl = document.getElementById('scan-processing');
const processingMsgEl= document.getElementById('scan-processing-msg');
// Add-stock modal elements
const addStockModalEl = document.getElementById('addStockModal');
const bsAddStockModal = addStockModalEl ? new bootstrap.Modal(addStockModalEl) : null;
const addStockItemName = document.getElementById('add-stock-item-name');
const addStockCurrentQty= document.getElementById('add-stock-current-qty');
const addStockUomLabel = document.getElementById('add-stock-uom-label');
const addStockQtyInput = document.getElementById('add-stock-qty');
const addStockCostInput = document.getElementById('add-stock-cost');
const addStockNotesInput= document.getElementById('add-stock-notes');
const addStockStatusEl = document.getElementById('add-stock-status');
const addStockConfirmBtn= document.getElementById('add-stock-confirm-btn');
let _addStockItemId = null;
let _lastScanData = null;
if (!modalEl || !videoEl || !canvasEl) return;
scanBtn.addEventListener('click', openScanner);
modalEl.addEventListener('hide.bs.modal', onModalClose);
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
if (addStockConfirmBtn) addStockConfirmBtn.addEventListener('click', submitAddStock);
// "Create new entry instead" hides the add-stock modal and pre-fills the create form
const addStockNewBtn = document.getElementById('add-stock-new-btn');
if (addStockNewBtn) addStockNewBtn.addEventListener('click', () => {
bsAddStockModal?.hide();
if (_lastScanData) fillFromScan(_lastScanData, /* skipDuplicatePrompt */ true);
});
window.addEventListener('beforeunload', releaseCamera);
// Pre-warm camera if browser has already granted permission (no prompt risk)
preWarmCamera();
// ── Open / close ──────────────────────────────────────────────────────
async function openScanner() {
if (!bsModal) return;
qrFound = false;
stopQrLoop();
hideProcessing();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (shutterWrap) shutterWrap.classList.add('d-none');
bsModal.show();
if (!stream) {
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
try {
stream = await startStream();
} catch (err) {
setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
if (shutterWrap) shutterWrap.classList.remove('d-none');
return;
}
}
if (videoEl.srcObject !== stream) videoEl.srcObject = stream;
if (videoEl.paused) { try { await videoEl.play(); } catch { /* ignore */ } }
setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
startQrLoop();
shutterTimer = setTimeout(() => {
if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none');
}, 5000);
}
function onModalClose() {
stopQrLoop();
hideProcessing();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
}
function stopQrLoop() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (rafId2) { cancelAnimationFrame(rafId2); rafId2 = null; }
if (jsqrTimer) { clearTimeout(jsqrTimer); jsqrTimer = null; }
if (shutterTimer){ clearTimeout(shutterTimer); shutterTimer = null; }
}
function releaseCamera() {
stopQrLoop();
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
videoEl.srcObject = null;
}
async function startStream() {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
}
// ── Camera pre-warm (no-prompt-on-page-load guarantee) ────────────────
async function preWarmCamera() {
// Only attempt if the Permissions API confirms the user has already granted access.
// Skipping when state is 'prompt' ensures we never show a browser dialog on page load.
try {
if (!navigator.permissions) return;
const perm = await navigator.permissions.query({ name: 'camera' });
if (perm.state !== 'granted') return;
stream = await startStream();
idleTimer = setTimeout(releaseCamera, IDLE_RELEASE_MS);
} catch { /* permission denied or getUserMedia unavailable — ignore */ }
}
// ── QR scanning: BarcodeDetector + jsQR in parallel ──────────────────
function startQrLoop() {
const hasBD = typeof BarcodeDetector !== 'undefined';
if (hasBD) {
startBarcodeDetectorLoop();
}
// Always start jsQR after a short delay — runs in parallel with BarcodeDetector
// (or immediately if BarcodeDetector isn't available). This ensures Prismatic-style
// QR codes that BarcodeDetector silently misses still get decoded by jsQR.
jsqrTimer = setTimeout(() => {
if (!qrFound) {
loadJsQR().then(() => { if (!qrFound) startJsQrLoop(); }).catch(() => {
if (!hasBD && shutterWrap) shutterWrap.classList.remove('d-none');
});
}
}, hasBD ? JSQR_DELAY_MS : 0);
}
// BarcodeDetector loop — canvas snapshot for reliability
async function startBarcodeDetectorLoop() {
let detector;
try {
const supported = await BarcodeDetector.getSupportedFormats();
detector = new BarcodeDetector({ formats: supported.length ? supported : ['qr_code'] });
} catch {
return; // BarcodeDetector unavailable — jsQR timer will handle it
}
const ctx = canvasEl.getContext('2d');
async function tick() {
if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA && videoEl.videoWidth > 0) {
try {
canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
const codes = await detector.detect(canvasEl);
if (codes.length > 0 && !qrFound) {
qrFound = true;
handleQrResult(codes[0].rawValue);
return;
}
} catch { /* frame not ready — try next */ }
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
}
// jsQR loop — separate canvas context to avoid interfering with BarcodeDetector
function startJsQrLoop() {
const canvas2 = document.createElement('canvas');
const ctx2 = canvas2.getContext('2d');
function tick() {
if (!stream || qrFound) return;
if (videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
canvas2.width = videoEl.videoWidth;
canvas2.height = videoEl.videoHeight;
ctx2.drawImage(videoEl, 0, 0);
const imageData = ctx2.getImageData(0, 0, canvas2.width, canvas2.height);
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'attemptBoth'
});
if (code && code.data && !qrFound) {
qrFound = true;
handleQrResult(code.data);
return;
}
}
rafId2 = requestAnimationFrame(tick);
}
rafId2 = requestAnimationFrame(tick);
}
function loadJsQR() {
return new Promise((resolve, reject) => {
if (window.jsQR) { resolve(); return; }
const s = document.createElement('script');
s.src = JSQR_CDN;
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load jsQR'));
document.head.appendChild(s);
});
}
// ── QR result handler ─────────────────────────────────────────────────
async function handleQrResult(url) {
stopQrLoop();
showProcessing('QR code found — looking up product…');
setScanBtnLoading(true);
try {
const fd = new FormData();
fd.append('qrUrl', url);
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
}
// ── Vision fallback: grab frame and POST ──────────────────────────────
async function captureFrame() {
stopQrLoop();
setScanBtnLoading(true);
if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
const ctx = canvasEl.getContext('2d');
const maxW = 1024;
const scale = videoEl.videoWidth > maxW ? maxW / videoEl.videoWidth : 1;
canvasEl.width = Math.round(videoEl.videoWidth * scale);
canvasEl.height = Math.round(videoEl.videoHeight * scale);
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
showProcessing('Reading label with AI…');
canvasEl.toBlob(async (blob) => {
try {
const base64 = await blobToBase64(blob);
const fd = new FormData();
fd.append('imageBase64', base64);
fd.append('mediaType', 'image/jpeg');
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
}, 'image/jpeg', 0.88);
} else {
// No live camera — fall back to file picker
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) { setScanBtnLoading(false); return; }
showProcessing('Reading label with AI…');
try {
const base64 = await blobToBase64(file);
const fd = new FormData();
fd.append('imageBase64', base64);
fd.append('mediaType', file.type || 'image/jpeg');
await submitScan(fd);
} finally {
setScanBtnLoading(false);
}
};
input.click();
}
}
// ── Submit to server and fill form ────────────────────────────────────
async function submitScan(fd) {
try {
const resp = await fetch(SCAN_URL, { method: 'POST', body: fd });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
hideProcessing();
setScanStatus('danger', data.errorMessage || 'Scan failed.');
return;
}
bsModal.hide();
if (data.existingInventoryId) {
// Product already in inventory — show inline add-stock prompt
_lastScanData = data;
_addStockItemId = data.existingInventoryId;
openAddStockModal(data);
} else {
fillFromScan(data);
}
} catch (err) {
hideProcessing();
setScanStatus('danger', 'Scan failed: ' + err.message);
}
}
// ── Add-stock modal ───────────────────────────────────────────────────
function openAddStockModal(data) {
if (!bsAddStockModal) { fillFromScan(data); return; }
const uom = data.existingUnitOfMeasure || 'lbs';
if (addStockItemName) addStockItemName.textContent = data.existingInventoryName || data.colorName || 'This product';
if (addStockCurrentQty) addStockCurrentQty.textContent = `${(data.existingQuantityOnHand ?? 0).toFixed(2)} ${uom}`;
if (addStockUomLabel) addStockUomLabel.textContent = uom;
if (addStockQtyInput) addStockQtyInput.value = '';
if (addStockCostInput) addStockCostInput.value = data.unitPrice > 0 ? data.unitPrice : '';
if (addStockNotesInput) addStockNotesInput.value = '';
if (addStockStatusEl) { addStockStatusEl.className = 'd-none'; addStockStatusEl.textContent = ''; }
if (addStockConfirmBtn) addStockConfirmBtn.disabled = false;
bsAddStockModal.show();
}
async function submitAddStock() {
const qty = parseFloat(addStockQtyInput?.value);
if (!qty || qty <= 0) {
showAddStockStatus('danger', 'Please enter a quantity greater than zero.');
return;
}
addStockConfirmBtn.disabled = true;
addStockConfirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
try {
const params = new URLSearchParams({
inventoryItemId: _addStockItemId,
quantity: qty,
});
const cost = parseFloat(addStockCostInput?.value);
if (cost > 0) params.append('unitCost', cost);
const notes = addStockNotesInput?.value?.trim();
if (notes) params.append('notes', notes);
const resp = await fetch('/Inventory/AddStock?' + params.toString(), { method: 'POST' });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (!data.success) {
showAddStockStatus('danger', data.errorMessage || 'Failed to add stock.');
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
return;
}
// Success — close modal and show confirmation on the form
bsAddStockModal.hide();
showFormStatus('success',
`<i class="bi bi-check-circle-fill me-1"></i>` +
`Added <strong>${qty.toFixed(2)} ${data.unitOfMeasure}</strong> to <strong>${data.itemName}</strong>. ` +
`New stock: ${(data.newQuantityOnHand ?? 0).toFixed(2)} ${data.unitOfMeasure}. ` +
`<a href="/Inventory/Details/${_addStockItemId}" class="alert-link">View item</a>`
);
} catch (err) {
showAddStockStatus('danger', 'Error: ' + err.message);
addStockConfirmBtn.disabled = false;
addStockConfirmBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Stock';
}
}
function showAddStockStatus(type, msg) {
if (!addStockStatusEl) return;
addStockStatusEl.className = `alert alert-${type} py-2 small`;
addStockStatusEl.textContent = msg;
}
// ── Fill the inventory form from scan result ───────────────────────────
function fillFromScan(data, skipDuplicatePrompt = false) {
const filled = [];
function setIf(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
function setIfEmpty(id, value, label) {
const el = document.getElementById(id);
if (el && value != null && String(value).trim() && !el.value.trim()) {
el.value = String(value).trim();
filled.push(label);
}
}
setIf('field-manufacturer', data.manufacturer, 'Manufacturer');
setIf('field-partnumber', data.manufacturerPartNumber, 'Part Number');
setIf('field-colorname', data.colorName, 'Color Name');
const nameEl = document.getElementById('field-name');
if (nameEl && !nameEl.value.trim() && data.colorName) {
nameEl.value = data.colorName;
filled.push('Name');
}
setIfEmpty('field-description', data.description, 'Description');
setIfEmpty('field-finish', data.finish, 'Finish');
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
if (data.unitPrice > 0) {
const costEl = document.getElementById('field-unitcost');
if (costEl && (parseFloat(costEl.value) || 0) === 0) {
costEl.value = data.unitPrice;
filled.push('Unit Cost');
}
}
if (data.requiresClearCoat != null) {
const cc = document.getElementById('field-clearcoat');
if (cc) { cc.checked = data.requiresClearCoat; filled.push('Clear Coat'); }
}
if (data.colorFamilies) {
const hiddenInput = document.getElementById('field-colorfamilies');
if (hiddenInput && !hiddenInput.value.trim()) {
const families = data.colorFamilies.split(',').map(s => s.trim()).filter(Boolean);
hiddenInput.value = families.join(',');
document.querySelectorAll('.color-family-chip').forEach(chip => {
chip.classList.toggle('active', families.includes(chip.dataset.family));
});
filled.push('Color Families');
}
}
setIf('field-specpageurl', data.productUrl, 'Product URL');
syncLink('field-specpageurl', 'field-specpageurl-link', data.productUrl);
setIf('field-sdsurl', data.sdsUrl, 'SDS');
syncLink('field-sdsurl', 'field-sdsurl-link', data.sdsUrl);
setIf('field-tdsurl', data.tdsUrl, 'TDS');
syncLink('field-tdsurl', 'field-tdsurl-link', data.tdsUrl);
if (data.imageUrl) {
const imgInput = document.getElementById('field-imageurl');
const imgEl = document.getElementById('field-imagepreview-img');
const imgWrap = document.getElementById('wrap-imagepreview');
if (imgInput) imgInput.value = data.imageUrl;
if (imgEl) imgEl.src = data.imageUrl;
if (imgWrap) imgWrap.style.display = '';
filled.push('Image');
}
const vendorSel = document.getElementById('field-vendor');
if (vendorSel && !vendorSel.value && data.vendorName) {
const needle = data.vendorName.toLowerCase();
const match = Array.from(vendorSel.options).find(o =>
o.text.toLowerCase().includes(needle) || needle.includes(o.text.toLowerCase().trim())
);
if (match) { vendorSel.value = match.value; filled.push('Vendor'); }
}
const catalogNote = data.wasInCatalog
? ' <span class="badge bg-secondary ms-1">From catalog</span>'
: data.addedToCatalog
? ' <span class="badge bg-success ms-1">Added to platform catalog</span>'
: '';
if (data.existingInventoryId && !skipDuplicatePrompt) {
// Duplicate handled by add-stock modal — don't show a banner here
} else if (data.existingInventoryId && skipDuplicatePrompt) {
const itemName = data.existingInventoryName || data.colorName || 'This product';
const filledNote = filled.length > 0 ? ` Fields pre-filled from scan.` : '';
showFormStatus('warning',
`<i class="bi bi-exclamation-triangle-fill me-1"></i>` +
`Creating a new entry — <strong>${itemName}</strong> already exists. ` +
`<a href="/Inventory/Details/${data.existingInventoryId}" class="alert-link">View existing item</a>` +
`${filledNote}${catalogNote}`
);
} else if (filled.length > 0) {
showFormStatus('success', `Filled from label scan: ${filled.join(', ')}.${catalogNote}`);
} else {
showFormStatus('warning', `Label scanned but no empty fields to fill.${catalogNote}`);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
function showProcessing(msg) {
if (processingEl) {
if (processingMsgEl) processingMsgEl.textContent = msg;
processingEl.style.display = 'flex';
}
}
function hideProcessing() {
if (processingEl) processingEl.style.display = 'none';
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function syncLink(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
function setScanStatus(type, msg) {
if (!scanStatusEl) return;
scanStatusEl.className = `alert alert-${type} py-2 small mb-0 mt-2`;
scanStatusEl.innerHTML = msg;
scanStatusEl.classList.remove('d-none');
}
function showFormStatus(type, msg) {
if (!statusEl) return;
statusEl.className = `alert alert-${type} py-2 small mb-3 alert-permanent`;
statusEl.innerHTML = msg;
statusEl.classList.remove('d-none');
}
function setScanBtnLoading(on) {
if (!shutterBtn) return;
shutterBtn.disabled = on;
shutterBtn.innerHTML = on
? '<span class="spinner-border spinner-border-sm me-1"></span>Reading…'
: '<i class="bi bi-camera me-1"></i>Scan Label Text';
}
})();