Add Custom Formula Item Templates with AI generation and wizard integration

Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:09:22 -04:00
parent e443457139
commit 1eba50cf0f
40 changed files with 12846 additions and 33 deletions
+227 -13
View File
@@ -17,7 +17,7 @@ let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shap
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai'
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai' | 'formula'
data: {}, // Collected field values
ai: { // AI-specific wizard state
phase: 'upload', // 'upload' | 'loading' | 'followup' | 'result'
@@ -126,11 +126,12 @@ function openWizard(editIndex = -1) {
if (editIndex >= 0) {
const item = quoteItems[editIndex];
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
: item.isCustomFormulaItem ? 'formula'
: 'calculated';
// Pre-fill wizard data from existing item
wz.data = JSON.parse(JSON.stringify(item)); // deep copy
@@ -333,8 +334,18 @@ function renderStep1Html() {
icon: 'bi-shop',
label: 'Retail / Merchandise',
desc: 'Off-the-shelf items — T-shirts, tumblers, apparel, or any product sold at a fixed price.'
},
{
type: 'formula',
icon: 'bi-calculator',
label: 'Custom Formula Item',
desc: 'Use a saved formula template to price complex fabricated items (roof curbs, enclosures, frames).'
}
].filter(t => t.type !== 'ai' || pageMeta.aiPhotoQuotesEnabled !== false);
].filter(t => {
if (t.type === 'ai') return pageMeta.aiPhotoQuotesEnabled !== false;
if (t.type === 'formula') return (pageMeta.customFormulaTemplates || []).length > 0;
return true;
});
return `<div class="row g-3">` +
types.map(t => `
@@ -370,6 +381,7 @@ function renderStep2Html() {
if (wz.itemType === 'labor') return renderLaborFields();
if (wz.itemType === 'ai') return renderAiPhotoFields();
if (wz.itemType === 'sales') return renderSalesFields();
if (wz.itemType === 'formula') return renderFormulaFields();
return '<p class="text-danger">Unknown item type.</p>';
}
@@ -809,6 +821,155 @@ document.addEventListener('mousedown', e => {
}
});
// ─── Formula Item Fields ──────────────────────────────────────────────────────
function renderFormulaFields() {
const templates = pageMeta.customFormulaTemplates || [];
if (!templates.length) return '<p class="text-warning">No formula templates found. Create one in Company Settings &rarr; Custom Formulas.</p>';
const selectedId = wz.data.customItemTemplateId || null;
const selected = templates.find(t => t.id === selectedId) || null;
// Parse stored field values if re-editing
let storedValues = {};
try { storedValues = JSON.parse(wz.data.formulaFieldValuesJson || '{}'); } catch { storedValues = {}; }
const templateOptions = templates.map(t =>
`<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${escHtml(t.name)}</option>`
).join('');
let fieldsHtml = '';
if (selected) {
let fields = [];
try { fields = JSON.parse(selected.fieldsJson || '[]'); } catch { fields = []; }
const diagramHtml = selected.diagramImagePath
? `<div class="mb-3 text-center">
<img src="${escHtml(selected.diagramImagePath)}" alt="Template diagram"
class="img-fluid rounded border" style="max-height:160px;" />
<div class="form-text">Reference diagram for this template</div>
</div>`
: '';
const rateField = selected.outputMode === 'FixedRate' && selected.defaultRate != null
? `<div class="row g-2 mb-2 align-items-center">
<div class="col-5"><label class="form-label mb-0">${escHtml(selected.rateLabel || 'Rate')}</label></div>
<div class="col-5"><input type="number" id="wz_formula_rate" class="form-control form-control-sm"
step="0.01" value="${storedValues['rate'] ?? selected.defaultRate}" /></div>
<div class="col-2 text-muted small">${escHtml(selected.rateLabel || '')}</div>
</div>`
: '';
fieldsHtml = `
${diagramHtml}
${rateField}
${fields.map(f => `
<div class="row g-2 mb-2 align-items-center">
<div class="col-5"><label class="form-label mb-0">${escHtml(f.label || f.name)}</label></div>
<div class="col-5">
<input type="number" id="wz_fld_${escHtml(f.name)}" class="form-control form-control-sm"
step="any" value="${storedValues[f.name] ?? f.defaultValue ?? ''}"
onchange="wzFormulaRecalc()" />
</div>
<div class="col-2 text-muted small">${escHtml(f.unit || '')}</div>
</div>`).join('')}
<div class="mt-3 p-2 border rounded bg-light d-flex align-items-center gap-3">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="wzFormulaRecalc()">
<i class="bi bi-calculator"></i> Calculate
</button>
<span id="wz_formula_result" class="fw-bold fs-5"></span>
<span class="text-muted small">${selected.outputMode === 'FixedRate' ? 'total price' : 'sq ft'}</span>
</div>`;
}
return `
<div class="mb-3">
<label class="form-label">Formula Template <span class="text-danger">*</span></label>
<select id="wz_formulaTemplate" class="form-select" onchange="wzFormulaTemplateChanged()">
<option value="">-- Select a template --</option>
${templateOptions}
</select>
<div id="err_formulaTemplate" class="text-danger small mt-1 d-none">Please select a template.</div>
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<input type="text" id="wz_formula_description" class="form-control"
value="${escHtml(wz.data.description || (selected ? selected.name : ''))}" />
</div>
<div class="mb-2 row g-2">
<div class="col-4">
<label class="form-label">Quantity</label>
<input type="number" id="wz_formula_qty" class="form-control" value="${wz.data.quantity || 1}" min="1" />
</div>
</div>
<div id="wz_formula_fields">${fieldsHtml}</div>
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>`;
}
window.wzFormulaTemplateChanged = function () {
const id = parseInt(document.getElementById('wz_formulaTemplate')?.value);
wz.data.customItemTemplateId = isNaN(id) ? null : id;
wz.data.formulaResult = null;
document.getElementById('wz_formula_fields').innerHTML = renderFormulaFieldInputs();
};
function renderFormulaFieldInputs() {
const templates = pageMeta.customFormulaTemplates || [];
const selected = templates.find(t => t.id === wz.data.customItemTemplateId);
if (!selected) return '';
// Re-render the inner fields portion
const tmp = renderFormulaFields();
// Extract just the fields section by re-rendering with selected
const div = document.createElement('div');
div.innerHTML = tmp;
return div.querySelector('#wz_formula_fields')?.innerHTML || '';
}
window.wzFormulaRecalc = async function () {
const templates = pageMeta.customFormulaTemplates || [];
const selected = templates.find(t => t.id === wz.data.customItemTemplateId);
if (!selected) return;
let fields = [];
try { fields = JSON.parse(selected.fieldsJson || '[]'); } catch { fields = []; }
const variables = {};
fields.forEach(f => {
const el = document.getElementById(`wz_fld_${f.name}`);
variables[f.name] = el ? (parseFloat(el.value) || 0) : (f.defaultValue || 0);
});
const rateEl = document.getElementById('wz_formula_rate');
if (rateEl) variables['rate'] = parseFloat(rateEl.value) || 0;
try {
const res = await fetch(pageMeta.formulaEvalUrl || '/CompanySettings/EvaluateFormula', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getRequestVerificationToken() },
body: JSON.stringify({ formula: selected.formula, variablesJson: JSON.stringify(variables) })
});
const data = await res.json();
const el = document.getElementById('wz_formula_result');
if (data.success && el) {
el.textContent = selected.outputMode === 'FixedRate'
? `$${Number(data.result).toFixed(2)}`
: `${Number(data.result).toFixed(3)} sq ft`;
el.className = 'fw-bold fs-5 text-success';
wz.data.formulaResult = data.result;
wz.data.formulaVariables = variables;
} else if (el) {
el.textContent = data.error || 'Error';
el.className = 'fw-bold text-danger';
}
} catch (e) {
const el = document.getElementById('wz_formula_result');
if (el) { el.textContent = 'Request failed'; el.className = 'fw-bold text-danger'; }
}
};
function getRequestVerificationToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
function renderAiPhotoFields() {
const existingPhotoHtml = wz.ai.tempIds.map((tid, i) => {
const previewUrl = wz.ai.previewUrls[i] || '';
@@ -2510,6 +2671,47 @@ function collectStep2() {
wz.data.isSalesItem = true;
}
if (wz.itemType === 'formula') {
const templateId = parseInt(document.getElementById('wz_formulaTemplate')?.value);
if (!templateId) {
document.getElementById('err_formulaTemplate')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_formulaTemplate')?.classList.add('d-none');
wz.data.customItemTemplateId = templateId;
}
if (wz.data.formulaResult == null) {
document.getElementById('err_formulaCalc')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_formulaCalc')?.classList.add('d-none');
}
if (valid) {
const templates = pageMeta.customFormulaTemplates || [];
const tmpl = templates.find(t => t.id === templateId);
const qty = parseInt(document.getElementById('wz_formula_qty')?.value) || 1;
const descEl = document.getElementById('wz_formula_description');
wz.data.quantity = qty;
wz.data.description = descEl?.value?.trim() || (tmpl?.name ?? 'Custom Formula Item');
wz.data.isCustomFormulaItem = true;
wz.data.formulaFieldValuesJson = JSON.stringify(wz.data.formulaVariables || {});
if (tmpl?.outputMode === 'FixedRate') {
wz.data.manualUnitPrice = parseFloat(wz.data.formulaResult) || 0;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
} else {
// SurfaceAreaSqFt mode — formula produced sq ft; standard engine prices it
wz.data.surfaceAreaSqFt = parseFloat(wz.data.formulaResult) || 0;
wz.data.manualUnitPrice = null;
wz.data.estimatedMinutes = 0;
}
}
}
if (wz.itemType === 'ai') {
if (!wz.ai.accepted || !wz.ai.result) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
@@ -2671,6 +2873,9 @@ function buildItemFromWizard() {
salesCatalogItemId: d.salesCatalogItemId || null,
sku: d.sku || null,
isAiItem: isAi,
isCustomFormulaItem: !!d.isCustomFormulaItem,
customItemTemplateId: d.customItemTemplateId || null,
formulaFieldValuesJson: d.formulaFieldValuesJson || null,
requiresSandblasting: false,
requiresMasking: false,
notes: d.notes || null,
@@ -2711,12 +2916,13 @@ function renderAllCards() {
}
function buildCardHtml(item, i) {
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: item.isCustomFormulaItem ? { label: 'Formula', cls: 'purple', icon: 'bi-calculator' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0;
const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
@@ -2855,6 +3061,11 @@ function writeHiddenFields() {
if (item.isAiItem) fields.push(h(p + '.IsAiItem', 'true'));
if (item.aiTags) fields.push(h(p + '.AiTags', item.aiTags));
if (item.aiPredictionId != null) fields.push(h(p + '.AiPredictionId', item.aiPredictionId));
if (item.isCustomFormulaItem) {
fields.push(h(p + '.IsCustomFormulaItem', 'true'));
if (item.customItemTemplateId != null) fields.push(h(p + '.CustomItemTemplateId', item.customItemTemplateId));
if (item.formulaFieldValuesJson) fields.push(h(p + '.FormulaFieldValuesJson', item.formulaFieldValuesJson));
}
(item.prepServices || []).forEach((ps, pi) => {
const pp = `${p}.PrepServices[${pi}]`;
@@ -3410,6 +3621,9 @@ function loadItemsFromTemplate(templateItems) {
isGenericItem: !!ti.isGenericItem,
isLaborItem: !!ti.isLaborItem,
isAiItem: false,
isCustomFormulaItem: false,
customItemTemplateId: null,
formulaFieldValuesJson: null,
requiresSandblasting: !!ti.requiresSandblasting,
requiresMasking: !!ti.requiresMasking,
notes: null,