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>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -40,4 +40,16 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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 80–120.</summary>
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
|
||||
/// <summary>Powder transfer efficiency percentage. Typical 60–75%.</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)
|
||||
|
||||
Generated
+9472
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9496
File diff suppressed because it is too large
Load Diff
+142
@@ -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);
|
||||
|
||||
@@ -267,6 +267,201 @@ 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,
|
||||
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"),
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -682,6 +682,200 @@ public class InventoryController : Controller
|
||||
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);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <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 and let Claude extract specs from its content
|
||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||
aiResult.SpecPageUrl = qrUrl;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
||||
{
|
||||
// Vision path: Claude reads the label photo directly
|
||||
aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");
|
||||
}
|
||||
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 });
|
||||
|
||||
// Search catalog by SKU first (most precise), then fall back to color name
|
||||
var sku = aiResult.ManufacturerPartNumber?.Trim();
|
||||
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
|
||||
var colorName = aiResult.ColorName?.Trim();
|
||||
|
||||
PowderCatalogItem? catalogMatch = null;
|
||||
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
|
||||
{
|
||||
var skuLower = sku.ToLower();
|
||||
var mfrLower = manufacturer.ToLower();
|
||||
var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
|
||||
catalogMatch = skuMatches.FirstOrDefault();
|
||||
}
|
||||
|
||||
var wasInCatalog = catalogMatch != null;
|
||||
var addedToCatalog = false;
|
||||
|
||||
// Auto-contribute: insert into platform catalog if we have the minimum viable fields
|
||||
// and this SKU isn't already there
|
||||
if (!wasInCatalog
|
||||
&& !string.IsNullOrEmpty(sku)
|
||||
&& !string.IsNullOrEmpty(manufacturer)
|
||||
&& !string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var newItem = new PowderCatalogItem
|
||||
{
|
||||
VendorName = manufacturer,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
CureTemperatureF = aiResult.CureTemperatureF,
|
||||
CureTimeMinutes = aiResult.CureTimeMinutes,
|
||||
Finish = aiResult.Finish,
|
||||
ColorFamilies = aiResult.ColorFamilies,
|
||||
RequiresClearCoat = aiResult.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
|
||||
TransferEfficiency= aiResult.TransferEfficiency,
|
||||
ImageUrl = aiResult.ImageUrl,
|
||||
ProductUrl = aiResult.SpecPageUrl,
|
||||
IsUserContributed = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _unitOfWork.PowderCatalog.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
addedToCatalog = true;
|
||||
_logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
|
||||
}
|
||||
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 Json(new
|
||||
{
|
||||
success = true,
|
||||
manufacturer = manufacturer,
|
||||
manufacturerPartNumber= sku,
|
||||
colorName = colorName,
|
||||
description = aiResult.Description,
|
||||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||||
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
|
||||
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
|
||||
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
|
||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? 0m,
|
||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||
sdsUrl = catalogMatch?.SdsUrl,
|
||||
tdsUrl = catalogMatch?.TdsUrl,
|
||||
vendorName = manufacturer,
|
||||
wasInCatalog = wasInCatalog,
|
||||
addedToCatalog = addedToCatalog,
|
||||
reasoning = aiResult.Reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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: 60–70%). 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: 60–70%). 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 {
|
||||
@@ -487,11 +483,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,58 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -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)} · ${esc(item.sku)} · $${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* In-browser powder label scanner for the Inventory Create/Edit forms.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User clicks "Scan Label" → modal opens, rear camera starts.
|
||||
* 2. jsQR runs in a requestAnimationFrame loop scanning the live video.
|
||||
* - QR detected automatically → POST qrUrl to /Inventory/ScanLabel → fill form.
|
||||
* 3. If no QR within ~5 s the "Scan Text" button appears as a fallback.
|
||||
* - User taps it → grab current frame → base64 → POST to /Inventory/ScanLabel → fill form.
|
||||
* 4. Either path: server searches catalog, auto-contributes if new, returns unified data.
|
||||
* 5. Modal closes, form fills using the same field IDs as inventory-catalog-lookup.js.
|
||||
*/
|
||||
(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 scanBtn = document.getElementById('scan-label-btn');
|
||||
const statusEl = document.getElementById('ai-lookup-status');
|
||||
|
||||
if (!scanBtn) return;
|
||||
|
||||
let stream = null; // MediaStream
|
||||
let rafId = null; // requestAnimationFrame handle
|
||||
let qrFound = false;
|
||||
let shutterTimer = null; // timer to reveal the fallback shutter button
|
||||
|
||||
// ── Modal bootstrap ───────────────────────────────────────────────────
|
||||
|
||||
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');
|
||||
|
||||
if (!modalEl || !videoEl || !canvasEl) return;
|
||||
|
||||
scanBtn.addEventListener('click', openScanner);
|
||||
modalEl.addEventListener('hide.bs.modal', stopCamera);
|
||||
if (shutterBtn) shutterBtn.addEventListener('click', captureFrame);
|
||||
|
||||
// ── Open / close ──────────────────────────────────────────────────────
|
||||
|
||||
async function openScanner() {
|
||||
if (!bsModal) return;
|
||||
qrFound = false;
|
||||
if (shutterWrap) shutterWrap.classList.add('d-none');
|
||||
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Starting camera…');
|
||||
bsModal.show();
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||
});
|
||||
videoEl.srcObject = stream;
|
||||
await videoEl.play();
|
||||
setScanStatus('info', 'Point the camera at the powder label — QR code will scan automatically.');
|
||||
await loadJsQR();
|
||||
startQrLoop();
|
||||
// Reveal the shutter fallback after 5 s in case there is no QR
|
||||
shutterTimer = setTimeout(() => {
|
||||
if (!qrFound && shutterWrap) shutterWrap.classList.remove('d-none');
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
setScanStatus('danger', 'Camera access denied or unavailable. ' + err.message);
|
||||
if (shutterWrap) shutterWrap.classList.remove('d-none'); // let them try vision path
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
if (shutterTimer) { clearTimeout(shutterTimer); shutterTimer = null; }
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
|
||||
// ── jsQR lazy load ────────────────────────────────────────────────────
|
||||
|
||||
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 scan loop ──────────────────────────────────────────────────────
|
||||
|
||||
function startQrLoop() {
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
|
||||
function tick() {
|
||||
if (!stream || qrFound) return;
|
||||
|
||||
if (videoEl.readyState === videoEl.HAVE_ENOUGH_DATA) {
|
||||
canvasEl.width = videoEl.videoWidth;
|
||||
canvasEl.height = videoEl.videoHeight;
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvasEl.width, canvasEl.height);
|
||||
const code = window.jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert'
|
||||
});
|
||||
|
||||
if (code && code.data) {
|
||||
qrFound = true;
|
||||
handleQrResult(code.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// ── QR result handler ─────────────────────────────────────────────────
|
||||
|
||||
async function handleQrResult(url) {
|
||||
stopCamera();
|
||||
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>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() {
|
||||
setScanStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Reading label…');
|
||||
setScanBtnLoading(true);
|
||||
|
||||
// If camera is running, grab the current frame; otherwise show file picker
|
||||
if (stream && videoEl.readyState >= videoEl.HAVE_ENOUGH_DATA) {
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
// Scale down to ≤1024px wide to keep payload small while preserving label text
|
||||
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);
|
||||
|
||||
stopCamera();
|
||||
|
||||
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 (e.g. desktop) — fall back to file picker
|
||||
stopCamera();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) { setScanBtnLoading(false); return; }
|
||||
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) {
|
||||
setScanStatus('danger', data.errorMessage || 'Scan failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
bsModal.hide();
|
||||
fillFromScan(data);
|
||||
|
||||
} catch (err) {
|
||||
setScanStatus('danger', 'Scan failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fill the inventory form from scan result ───────────────────────────
|
||||
|
||||
function fillFromScan(data) {
|
||||
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');
|
||||
|
||||
// Name field: use color name if blank
|
||||
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 (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 blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// Strip the data URI prefix — server only wants the raw base64
|
||||
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';
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user