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
@@ -0,0 +1,175 @@
(function () {
'use strict';
const lookupBtn = document.getElementById('powder-ai-lookup-btn');
const urlBtn = document.getElementById('powder-ai-url-btn');
const statusEl = document.getElementById('ai-lookup-status');
if (!lookupBtn || !statusEl) return;
const endpoints = {
lookup: '/PowderCatalog/AiLookup',
byUrl: '/PowderCatalog/AiAugmentFromUrl'
};
lookupBtn.addEventListener('click', async function () {
const vendorName = getValue('field-vendorname');
const colorName = getValue('field-colorname');
const sku = getValue('field-sku');
if (!vendorName && !colorName && !sku) {
showStatus('warning', 'Enter a vendor, color name, or SKU first.');
return;
}
await runLookup(endpoints.lookup, {
vendorName: vendorName,
colorName: colorName,
sku: sku
}, 'Searching the web for missing powder specs and documents...');
});
urlBtn?.addEventListener('click', async function () {
const productUrl = getValue('field-producturl');
const colorName = getValue('field-colorname');
if (!productUrl) {
showStatus('warning', 'Add a product URL first, then try AI From URL.');
return;
}
await runLookup(endpoints.byUrl, {
productUrl: productUrl,
colorName: colorName
}, 'Reading the product page for missing specs and document links...');
});
async function runLookup(url, payload, loadingMessage) {
setButtonsDisabled(true);
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>' + loadingMessage);
try {
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => formData.append(key, value || ''));
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
if (token) formData.append('__RequestVerificationToken', token);
const response = await fetch(url, { method: 'POST', body: formData });
const data = await response.json();
if (!response.ok || !data.success) {
showStatus('danger', 'AI lookup failed: ' + (data.errorMessage || 'Unknown error.'));
return;
}
const filled = applyLookupResult(data);
const reasoning = data.reasoning ? `<div class="text-muted mt-1">${escapeHtml(data.reasoning)}</div>` : '';
if (filled.length > 0) {
showStatus('success', `Filled missing fields: ${filled.join(', ')}.${reasoning}`);
} else {
showStatus('warning', 'AI found the product, but there were no empty specs or docs to fill.' + reasoning);
}
} catch (error) {
showStatus('danger', 'AI lookup request failed: ' + error.message);
} finally {
setButtonsDisabled(false);
}
}
function applyLookupResult(data) {
const filled = [];
fillIfEmpty('field-vendorname', data.manufacturer || data.vendorName, 'Vendor', filled);
fillIfEmpty('field-sku', data.manufacturerPartNumber, 'SKU', filled);
fillIfEmpty('field-colorname', data.colorName, 'Color Name', filled);
fillIfEmpty('field-description', data.description, 'Description', filled, true);
fillIfEmpty('field-finish', data.finish, 'Finish', filled);
fillIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp', filled);
fillIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time', filled);
fillIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage', filled);
fillIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency', filled);
fillIfEmpty('field-producturl', data.specPageUrl, 'Product URL', filled);
fillIfEmpty('field-imageurl', data.imageUrl, 'Image URL', filled);
fillIfEmpty('field-sdsurl', data.sdsUrl, 'SDS URL', filled);
fillIfEmpty('field-tdsurl', data.tdsUrl, 'TDS URL', filled);
fillIfEmpty('field-colorfamilies', data.colorFamilies, 'Color Families', filled);
if (data.unitCostPerLb !== null && data.unitCostPerLb !== undefined) {
const unitPrice = document.getElementById('field-unitprice');
const current = unitPrice ? parseFloat(unitPrice.value) || 0 : 0;
if (unitPrice && current === 0) {
unitPrice.value = String(data.unitCostPerLb).trim();
filled.push('Unit Price');
}
}
const clearCoat = document.getElementById('field-clearcoat');
if (clearCoat && isEmptyValue(clearCoat.value) && data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
clearCoat.value = data.requiresClearCoat ? 'true' : 'false';
filled.push('Requires Clear Coat');
}
syncLinkButton('field-producturl', 'field-producturl-link');
syncLinkButton('field-sdsurl', 'field-sdsurl-link');
syncLinkButton('field-tdsurl', 'field-tdsurl-link');
syncLinkButton('field-applicationguideurl', 'field-applicationguideurl-link');
return filled;
}
function fillIfEmpty(id, value, label, filled, isTextarea) {
const el = document.getElementById(id);
if (!el) return;
const normalized = value !== null && value !== undefined ? String(value).trim() : '';
const current = isTextarea ? (el.value || '').trim() : (el.value || '').trim();
if (!normalized || current) return;
el.value = normalized;
filled.push(label);
}
function getValue(id) {
return document.getElementById(id)?.value?.trim() || '';
}
function syncLinkButton(inputId, linkId) {
const input = document.getElementById(inputId);
const link = document.getElementById(linkId);
if (!input || !link) return;
if (input.value && input.value.trim()) {
link.href = input.value.trim();
link.classList.remove('d-none');
} else {
link.href = '#';
link.classList.add('d-none');
}
}
function setButtonsDisabled(disabled) {
lookupBtn.disabled = disabled;
if (urlBtn) urlBtn.disabled = disabled;
}
function isEmptyValue(value) {
return value === null || value === undefined || String(value).trim() === '';
}
function showStatus(type, message) {
statusEl.className = `alert alert-${type} py-2 small mb-3`;
statusEl.innerHTML = message;
statusEl.classList.remove('d-none');
}
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
})();