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:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user