Add platform powder catalog management UI with full CRUD and AI lookup

- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 00:27:44 -04:00
parent 713efbc2b6
commit 11a1b91be1
15 changed files with 8642 additions and 94 deletions
@@ -27,6 +27,9 @@ namespace PowderCoating.Infrastructure.Services;
/// </summary>
public class InventoryAiLookupService : IInventoryAiLookupService
{
private const decimal DefaultTransferEfficiency = 65m;
private const decimal TheoreticalCoverageAtOneMilFactor = 192.3m;
private readonly IConfiguration _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<InventoryAiLookupService> _logger;
@@ -47,6 +50,7 @@ Respond ONLY with a valid JSON object — no markdown, no explanation:
""cureTimeMinutes"": number or null,
""colorFamilies"": ""comma-separated list from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — or null if unknown"",
""requiresClearCoat"": true or false or null,
""specificGravity"": number or null,
""coverageSqFtPerLb"": number or null,
""transferEfficiency"": number or null,
""unitCostPerLb"": number or null,
@@ -250,6 +254,7 @@ Rules:
result.CureTimeMinutes = GetInt(parsed, "cureTimeMinutes");
result.ColorFamilies = GetString(parsed, "colorFamilies");
result.RequiresClearCoat = GetBool(parsed, "requiresClearCoat");
result.SpecificGravity = GetDecimal(parsed, "specificGravity");
result.CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb");
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
@@ -260,6 +265,7 @@ Rules:
result.ImageUrl = pageImageUrl;
result.Reasoning = GetString(parsed, "reasoning");
ApplyPowderFallbacks(result);
return result;
}
catch (Exception ex)
@@ -366,7 +372,7 @@ Rules:
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
var result = new InventoryAiLookupResult
{
Success = true,
Manufacturer = GetString(parsed, "manufacturer"),
@@ -378,11 +384,15 @@ Rules:
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
SpecificGravity = GetDecimal(parsed, "specificGravity"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
VendorName = GetString(parsed, "vendorName"),
Reasoning = GetString(parsed, "reasoning"),
};
ApplyPowderFallbacks(result);
return result;
}
catch (Exception ex)
{
@@ -447,7 +457,7 @@ Rules:
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
return new InventoryAiLookupResult
var result = new InventoryAiLookupResult
{
Success = true,
Manufacturer = GetString(parsed, "manufacturer"),
@@ -460,6 +470,7 @@ Rules:
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
ColorFamilies = GetString(parsed, "colorFamilies"),
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
SpecificGravity = GetDecimal(parsed, "specificGravity"),
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb"),
@@ -470,6 +481,9 @@ Rules:
ImageUrl = pageImageUrl,
Reasoning = GetString(parsed, "reasoning"),
};
ApplyPowderFallbacks(result);
return result;
}
catch (Exception ex)
{
@@ -1226,4 +1240,15 @@ Rules:
}
return null;
}
private static void ApplyPowderFallbacks(InventoryAiLookupResult result)
{
result.TransferEfficiency ??= DefaultTransferEfficiency;
if (!result.CoverageSqFtPerLb.HasValue && result.SpecificGravity is > 0)
{
var calculatedCoverage = TheoreticalCoverageAtOneMilFactor / result.SpecificGravity.Value;
result.CoverageSqFtPerLb = Math.Round(calculatedCoverage, 2, MidpointRounding.AwayFromZero);
}
}
}