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:
@@ -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…</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…';
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 ?? '';
|
||||
}
|
||||
})();
|
||||
@@ -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 → 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,
|
||||
|
||||
Reference in New Issue
Block a user