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
@@ -106,6 +106,11 @@
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="custom-formulas-tab" data-bs-toggle="tab" data-bs-target="#custom-formulas" type="button" role="tab">
<i class="bi bi-calculator"></i> Custom Formulas
</button>
</li>
</ul>
<!-- Tabs Content -->
@@ -2054,6 +2059,143 @@
</div>
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Custom Formula Item Templates</h5>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template
</button>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Define reusable pricing formulas for complex fabricated items (roof curbs, enclosures, frames).
When a user adds a formula item to a quote or job, they fill in the measurements and the formula
calculates the price automatically.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="cfTemplatesTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Output Mode</th>
<th>Fields</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody id="cfTemplatesBody">
<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Formula Template Modal -->
<div class="modal fade" id="cfModal" tabindex="-1" aria-labelledby="cfModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfModalLabel">New Formula Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfId" value="0" />
<div class="row g-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" id="cfName" class="form-control" placeholder="e.g. Roof Curb" maxlength="100" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="cfDescription" class="form-control" maxlength="500" />
</div>
<div class="mb-3">
<label class="form-label">Output Mode <span class="text-danger">*</span></label>
<select id="cfOutputMode" class="form-select" onchange="cfToggleRateFields()">
<option value="FixedRate">Fixed Rate &mdash; formula &rarr; $ amount</option>
<option value="SurfaceAreaSqFt">Surface Area &mdash; formula &rarr; sq ft (standard pricing engine prices it)</option>
</select>
</div>
<div id="cfRateFields">
<div class="mb-3">
<label class="form-label">Default Rate</label>
<input type="number" id="cfDefaultRate" class="form-control" step="0.01" placeholder="e.g. 0.85" />
<div class="form-text">Used as the <code>rate</code> variable if not overridden per-quote.</div>
</div>
<div class="mb-3">
<label class="form-label">Rate Label</label>
<input type="text" id="cfRateLabel" class="form-control" maxlength="50" placeholder="e.g. $/sq ft" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Formula <span class="text-danger">*</span></label>
<input type="text" id="cfFormula" class="form-control font-monospace" placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate" />
<div class="form-text">NCalc expression. Available variables: field names + <code>rate</code>.</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea id="cfNotes" class="form-control" rows="2" maxlength="1000"></textarea>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="cfIsActive" class="form-check-input" checked />
<label class="form-check-label" for="cfIsActive">Active (show in quote/job wizard)</label>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Fields</label>
<div class="form-text mb-2">Define the measurement inputs users will fill in.</div>
<div id="cfFieldsList"></div>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="cfAddField()">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
<div class="mb-3">
<label class="form-label">Formula Test</label>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="cfTestFormula()">
<i class="bi bi-play-circle"></i> Run
</button>
<span id="cfTestResult" class="fw-bold"></span>
</div>
<div class="form-text">Uses the default values from your field list.</div>
</div>
<div class="mb-3">
<label class="form-label">Diagram / Shop Drawing</label>
<div id="cfDiagramPreview" class="mb-2" style="display:none;">
<img id="cfDiagramImg" src="" alt="Diagram" class="img-fluid rounded border" style="max-height:180px;" />
</div>
<input type="file" id="cfDiagramFile" class="form-control form-control-sm" accept="image/*" onchange="cfPreviewDiagram(event)" />
<div class="form-text">Optional. Upload a shop drawing or photo to help users recognize this item.</div>
</div>
<div class="mb-3">
<label class="form-label">AI Formula Generator</label>
<div class="input-group">
<input type="text" id="cfAiPrompt" class="form-control" placeholder="Describe the item, e.g. 'Rectangular roof curb with flanged base'" />
<button type="button" class="btn btn-outline-secondary" onclick="cfGenerateFromAi()" id="cfAiBtn">
<i class="bi bi-stars"></i> Generate
</button>
</div>
<div class="form-text">Claude will suggest a formula, fields, and mode. You can edit before saving.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="cfSave()">
<i class="bi bi-floppy me-1"></i>Save Template
</button>
</div>
</div>
</div>
</div>
@@ -3289,6 +3431,17 @@
const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show();
}
if (urlParams.get('tab') === 'custom-formulas') {
const btn = document.querySelector('[data-bs-target="#custom-formulas"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script>
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script>
// Load formula templates when the tab is first shown
document.querySelector('[data-bs-target="#custom-formulas"]').addEventListener('shown.bs.tab', () => {
if (!window._cfLoaded) { cfLoadTemplates(); window._cfLoaded = true; }
});
</script>
}
@@ -0,0 +1,117 @@
@{
ViewData["Title"] = "Custom Formula Item Templates &mdash; Help";
}
<div class="row g-4">
<div class="col-lg-9">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help Center</a></li>
<li class="breadcrumb-item active">Custom Formula Item Templates</li>
</ol>
</nav>
<h1 class="h3 mb-1"><i class="bi bi-calculator text-info me-2"></i>Custom Formula Item Templates</h1>
<p class="text-muted mb-4">Build reusable pricing formulas for complex fabricated items.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">What are formula templates?</h2>
<p>
Some items &mdash; roof curbs, electrical enclosures, welded frames &mdash; have prices that depend on
exact measurements rather than estimated surface area. Custom Formula Item Templates let you define a
reusable NCalc expression that automatically calculates the price (or surface area) once a user enters
the measurements.
</p>
<p>
Templates are created once in <strong>Company Settings &rarr; Custom Formulas</strong> and then
appear as a selectable item type in the quote and job item wizards.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Output modes</h2>
<div class="table-responsive">
<table class="table table-sm">
<thead class="table-light">
<tr><th>Mode</th><th>Formula output</th><th>How it&rsquo;s priced</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Fixed Rate</strong></td>
<td>A dollar amount</td>
<td>Stored as <code>ManualUnitPrice</code>; multiplied by quantity for the line total.</td>
</tr>
<tr>
<td><strong>Surface Area</strong></td>
<td>Square footage</td>
<td>Passed to the standard coating engine; priced per your operating cost rates.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Creating a template</h2>
<ol>
<li>Go to <strong>Company Settings &rarr; Custom Formulas</strong> and click <strong>New Template</strong>.</li>
<li>Enter a name and choose the output mode.</li>
<li>Add the measurement <strong>fields</strong> users will fill in (e.g. <code>length_in</code>, <code>width_in</code>).</li>
<li>Write the <strong>formula</strong> using those field names. Example for a box surface area in inches:
<pre class="bg-light p-2 rounded mt-1 mb-0">2*(length_in*width_in + length_in*height_in + width_in*height_in) / 144 * rate</pre>
</li>
<li>Click <strong>Run</strong> to test the formula with your default values.</li>
<li>Optionally upload a <strong>diagram image</strong> &mdash; users will see it when they select this template.</li>
<li>Save the template.</li>
</ol>
<h3 class="h6 mt-3">Using AI to generate a formula</h3>
<p>
In the template editor, enter a description of the item in the <strong>AI Formula Generator</strong> box
and optionally attach a diagram image. Claude will suggest a formula, field list, and output mode.
Review and adjust before saving.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Adding a formula item to a quote or job</h2>
<ol>
<li>In the item wizard, select <strong>Custom Formula Item</strong> (only visible if at least one active template exists).</li>
<li>Choose a template from the dropdown. The template&rsquo;s diagram will appear for reference.</li>
<li>Enter the measurements and click <strong>Calculate</strong> to preview the result.</li>
<li>Adjust the description and quantity, then continue to the coatings and prep steps.</li>
</ol>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">NCalc formula reference</h2>
<p>Formulas use <a href="https://ncalc.github.io/ncalc/" target="_blank" rel="noopener">NCalc</a> syntax:</p>
<ul>
<li>Standard operators: <code>+ - * / % Pow(b, e)</code></li>
<li>Functions: <code>Abs(x) Round(x, d) Max(a, b) Min(a, b) Sqrt(x)</code></li>
<li>Variable names must start with a letter and contain only letters, digits, or underscores.</li>
<li>The reserved variable <code>rate</code> is pre-populated from the template&rsquo;s Default Rate.</li>
</ul>
<p class="mb-0">Examples:</p>
<ul class="mb-0">
<li><code>2*(l*w + l*h + w*h) / 144 * rate</code> &mdash; box surface area (inches &rarr; sqft &rarr; dollars)</li>
<li><code>Pow(diameter_in / 2, 2) * 3.14159 / 144 * rate</code> &mdash; circular face area</li>
<li><code>(l_ft * w_ft) * rate</code> &mdash; flat panel in feet</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-3 d-none d-lg-block">
@await Html.PartialAsync("_HelpNav")
</div>
</div>
@@ -258,6 +258,22 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-calculator text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Custom Formula Item Templates</h5>
<p class="card-text text-muted small mb-2">Build reusable NCalc pricing formulas for complex fabricated items like roof curbs, enclosures, and frames.</p>
<a asp-controller="Help" asp-action="CustomFormulaTemplates" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -397,6 +397,11 @@
complexity = item.Complexity,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -438,6 +443,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -358,6 +358,8 @@
<tr data-item-id="@item.Id">
<td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
@if (item.IsCustomFormulaItem) { <span class="badge bg-secondary ms-1"><i class="bi bi-calculator me-1"></i>Formula</span> }
@if (item.Coats != null && item.Coats.Any())
{
<br />
@@ -384,6 +384,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -425,6 +428,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -137,6 +137,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -464,6 +464,11 @@
manualUnitPrice = item.ManualUnitPrice,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -505,6 +510,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -1300,6 +1300,7 @@
<span class="fw-semibold">@(item.Description ?? item.CatalogItemName ?? "(no description)")</span>
@if (item.CatalogItemId.HasValue) { <span class="badge bg-primary ms-1">Catalog</span> }
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
@if (item.IsCustomFormulaItem) { <span class="badge bg-secondary ms-1"><i class="bi bi-calculator me-1"></i>Formula</span> }
@if (item.SurfaceAreaSqFt > 0 || item.EstimatedMinutes > 0)
{
<span class="text-muted ms-2" style="font-size:.8rem;">
@@ -501,6 +501,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
includePrepCost = item.IncludePrepCost,
complexity = item.Complexity,
aiTags = item.AiTags,
@@ -548,6 +551,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),