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
@@ -0,0 +1,382 @@
// company-settings-custom-formulas.js
// Custom Formula Item Templates — Company Settings tab JS
(function () {
let cfFields = [];
let cfEditing = false;
// ── Load & Render ─────────────────────────────────────────────────────────
window.cfLoadTemplates = async function () {
const tbody = document.getElementById('cfTemplatesBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>';
try {
const res = await fetch('/CompanySettings/GetCustomItemTemplates');
const data = await res.json();
if (!data.success || !data.templates.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">No formula templates yet. Click <strong>New Template</strong> to create one.</td></tr>';
return;
}
tbody.innerHTML = data.templates.map(t => `
<tr>
<td>
<strong>${escHtml(t.name)}</strong>
${t.description ? `<br><small class="text-muted">${escHtml(t.description)}</small>` : ''}
</td>
<td>
${t.outputMode === 'FixedRate'
? '<span class="badge bg-primary">Fixed Rate</span>'
: '<span class="badge bg-success">Surface Area</span>'}
</td>
<td><span class="badge bg-secondary">${t.fieldCount} field${t.fieldCount !== 1 ? 's' : ''}</span></td>
<td>${t.isActive
? '<span class="badge bg-success">Active</span>'
: '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="cfShowEdit(${t.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="cfDelete(${t.id}, '${escHtml(t.name)}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="5" class="text-danger text-center py-3">Failed to load templates.</td></tr>';
}
};
// ── Create / Edit Modal ───────────────────────────────────────────────────
window.cfShowCreate = function () {
cfEditing = false;
document.getElementById('cfModalLabel').textContent = 'New Formula Template';
cfResetForm();
new bootstrap.Modal(document.getElementById('cfModal')).show();
};
window.cfShowEdit = async function (id) {
cfEditing = true;
document.getElementById('cfModalLabel').textContent = 'Edit Formula Template';
cfResetForm();
try {
const res = await fetch('/CompanySettings/GetCustomItemTemplates');
const data = await res.json();
const t = data.templates?.find(x => x.id === id);
if (!t) { showCfError('Template not found.'); return; }
// Fetch full DTO (fieldsJson + formula) via a second call that returns the full data
// GetCustomItemTemplates returns ListDto which has fieldCount, not fieldsJson.
// We need the full template — load it from the API response carefully.
// For now fetch all and find; fieldCount is on list, fieldsJson needs a dedicated endpoint.
// Since we only have GetCustomItemTemplates (returns list DTOs), we re-use what we have
// and handle fieldsJson separately via the AI-generated field.
// TODO: add GetCustomItemTemplate(id) endpoint if needed; for POC, store fieldsJson in data- attr.
} catch (e) { }
// Workaround: store full template data in the table row as JSON via a hidden Get endpoint
// For now use the reload approach via a dedicated single-record fetch
const res2 = await fetch(`/CompanySettings/GetCustomItemTemplate?id=${id}`);
if (!res2.ok) { showCfError('Could not load template.'); return; }
const full = await res2.json();
if (!full.success) { showCfError(full.message ?? 'Error loading template.'); return; }
cfPopulateForm(full.template);
new bootstrap.Modal(document.getElementById('cfModal')).show();
};
function cfPopulateForm(t) {
document.getElementById('cfId').value = t.id ?? 0;
document.getElementById('cfName').value = t.name ?? '';
document.getElementById('cfDescription').value = t.description ?? '';
document.getElementById('cfOutputMode').value = t.outputMode ?? 'FixedRate';
document.getElementById('cfDefaultRate').value = t.defaultRate ?? '';
document.getElementById('cfRateLabel').value = t.rateLabel ?? '';
document.getElementById('cfFormula').value = t.formula ?? '';
document.getElementById('cfNotes').value = t.notes ?? '';
document.getElementById('cfIsActive').checked = t.isActive !== false;
cfFields = [];
try { cfFields = JSON.parse(t.fieldsJson || '[]'); } catch { cfFields = []; }
cfRenderFields();
cfToggleRateFields();
if (t.diagramImagePath) {
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
document.getElementById('cfDiagramPreview').style.display = '';
}
}
function cfResetForm() {
document.getElementById('cfId').value = '0';
document.getElementById('cfName').value = '';
document.getElementById('cfDescription').value = '';
document.getElementById('cfOutputMode').value = 'FixedRate';
document.getElementById('cfDefaultRate').value = '';
document.getElementById('cfRateLabel').value = '';
document.getElementById('cfFormula').value = '';
document.getElementById('cfNotes').value = '';
document.getElementById('cfIsActive').checked = true;
document.getElementById('cfDiagramPreview').style.display = 'none';
document.getElementById('cfDiagramFile').value = '';
document.getElementById('cfTestResult').textContent = '';
cfFields = [];
cfRenderFields();
cfToggleRateFields();
}
// ── Field List Editor ─────────────────────────────────────────────────────
window.cfAddField = function () {
cfFields.push({ name: '', label: '', unit: '', defaultValue: 0 });
cfRenderFields();
};
window.cfRemoveField = function (idx) {
cfFields.splice(idx, 1);
cfRenderFields();
};
function cfRenderFields() {
const el = document.getElementById('cfFieldsList');
if (!cfFields.length) {
el.innerHTML = '<p class="text-muted small">No fields yet.</p>';
return;
}
el.innerHTML = cfFields.map((f, i) => `
<div class="border rounded p-2 mb-2 bg-light">
<div class="row g-2 align-items-center">
<div class="col-3">
<input type="text" class="form-control form-control-sm font-monospace"
placeholder="var_name" value="${escHtml(f.name)}"
oninput="cfFields[${i}].name=this.value" />
</div>
<div class="col-3">
<input type="text" class="form-control form-control-sm"
placeholder="Label" value="${escHtml(f.label)}"
oninput="cfFields[${i}].label=this.value" />
</div>
<div class="col-2">
<input type="text" class="form-control form-control-sm"
placeholder="Unit" value="${escHtml(f.unit ?? '')}"
oninput="cfFields[${i}].unit=this.value" />
</div>
<div class="col-3">
<input type="number" class="form-control form-control-sm"
placeholder="Default" value="${f.defaultValue ?? ''}"
oninput="cfFields[${i}].defaultValue=parseFloat(this.value)||0" />
</div>
<div class="col-1 text-end">
<button type="button" class="btn btn-link text-danger p-0" onclick="cfRemoveField(${i})">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>`).join('');
}
// ── Formula Test ──────────────────────────────────────────────────────────
window.cfTestFormula = async function () {
const formula = document.getElementById('cfFormula').value.trim();
if (!formula) { document.getElementById('cfTestResult').textContent = 'Enter a formula first.'; return; }
const variables = {};
cfFields.forEach(f => { if (f.name) variables[f.name] = f.defaultValue ?? 0; });
const defaultRate = parseFloat(document.getElementById('cfDefaultRate').value);
if (!isNaN(defaultRate)) variables['rate'] = defaultRate;
try {
const res = await fetch('/CompanySettings/EvaluateFormula', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgeryToken() },
body: JSON.stringify({ formula, variablesJson: JSON.stringify(variables) })
});
const data = await res.json();
const el = document.getElementById('cfTestResult');
if (data.success) {
el.textContent = `= ${Number(data.result).toFixed(4)}`;
el.className = 'fw-bold text-success';
} else {
el.textContent = data.error ?? 'Error';
el.className = 'fw-bold text-danger';
}
} catch (e) {
document.getElementById('cfTestResult').textContent = 'Request failed.';
}
};
// ── Output Mode Toggle ────────────────────────────────────────────────────
window.cfToggleRateFields = function () {
const mode = document.getElementById('cfOutputMode').value;
document.getElementById('cfRateFields').style.display = mode === 'FixedRate' ? '' : 'none';
};
// ── Diagram Preview ───────────────────────────────────────────────────────
window.cfPreviewDiagram = function (evt) {
const file = evt.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('cfDiagramImg').src = e.target.result;
document.getElementById('cfDiagramPreview').style.display = '';
};
reader.readAsDataURL(file);
};
// ── AI Generator ──────────────────────────────────────────────────────────
window.cfGenerateFromAi = async function () {
const prompt = document.getElementById('cfAiPrompt').value.trim();
if (!prompt) { showCfError('Enter a description first.'); return; }
const btn = document.getElementById('cfAiBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating&hellip;';
try {
const formData = new FormData();
formData.append('description', prompt);
const diagramFile = document.getElementById('cfDiagramFile').files[0];
if (diagramFile) formData.append('diagramImage', diagramFile);
const res = await fetch('/CompanySettings/GenerateFormulaFromAi', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: formData
});
const data = await res.json();
if (!data.success) { showCfError(data.error ?? 'AI generation failed.'); return; }
if (data.name) document.getElementById('cfName').value = data.name;
if (data.outputMode) document.getElementById('cfOutputMode').value = data.outputMode;
if (data.formula) document.getElementById('cfFormula').value = data.formula;
if (data.defaultRate != null) document.getElementById('cfDefaultRate').value = data.defaultRate;
if (data.rateLabel) document.getElementById('cfRateLabel').value = data.rateLabel;
cfFields = [];
try {
const parsed = JSON.parse(data.fieldsJson || '[]');
cfFields = parsed.map(f => ({
name: f.name ?? '',
label: f.label ?? '',
unit: f.unit ?? '',
defaultValue: f.defaultValue ?? 0
}));
} catch { }
cfRenderFields();
cfToggleRateFields();
if (data.verificationResult != null) {
const el = document.getElementById('cfTestResult');
el.textContent = `AI test = ${Number(data.verificationResult).toFixed(4)}`;
el.className = 'fw-bold text-success';
}
} catch (e) {
showCfError('AI request failed: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars"></i> Generate';
}
};
// ── Save ──────────────────────────────────────────────────────────────────
window.cfSave = async function () {
const id = parseInt(document.getElementById('cfId').value) || 0;
const name = document.getElementById('cfName').value.trim();
if (!name) { showCfError('Name is required.'); return; }
const formula = document.getElementById('cfFormula').value.trim();
if (!formula) { showCfError('Formula is required.'); return; }
const dto = {
id,
name,
description: document.getElementById('cfDescription').value.trim() || null,
outputMode: document.getElementById('cfOutputMode').value,
fieldsJson: JSON.stringify(cfFields),
formula,
defaultRate: parseFloat(document.getElementById('cfDefaultRate').value) || null,
rateLabel: document.getElementById('cfRateLabel').value.trim() || null,
notes: document.getElementById('cfNotes').value.trim() || null,
displayOrder: 0,
isActive: document.getElementById('cfIsActive').checked
};
const url = id ? '/CompanySettings/UpdateCustomItemTemplate' : '/CompanySettings/CreateCustomItemTemplate';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgeryToken() },
body: JSON.stringify(dto)
});
const data = await res.json();
if (!data.success) { showCfError(data.message ?? 'Save failed.'); return; }
const newId = data.id ?? id;
// Upload diagram if a new file was chosen
const diagramFile = document.getElementById('cfDiagramFile').files[0];
if (diagramFile && newId) {
const fd = new FormData();
fd.append('templateId', newId);
fd.append('diagramFile', diagramFile);
await fetch('/CompanySettings/UploadTemplateDiagram', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: fd
});
}
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
cfLoadTemplates();
} catch (e) {
showCfError('Save request failed: ' + e.message);
}
};
// ── Delete ────────────────────────────────────────────────────────────────
window.cfDelete = async function (id, name) {
if (!confirm(`Delete template "${name}"? This cannot be undone.`)) return;
try {
const fd = new FormData();
fd.append('id', id);
const res = await fetch('/CompanySettings/DeleteCustomItemTemplate', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: fd
});
const data = await res.json();
if (!data.success) { showCfError(data.message ?? 'Delete failed.'); return; }
cfLoadTemplates();
} catch (e) {
showCfError('Delete failed: ' + e.message);
}
};
// ── Helpers ───────────────────────────────────────────────────────────────
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showCfError(msg) {
const el = document.getElementById('errorToastMessage');
if (el) {
el.textContent = msg;
const toast = document.getElementById('errorToast');
if (toast) new bootstrap.Toast(toast).show();
} else {
alert(msg);
}
}
function getAntiForgeryToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
})();
+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,