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>
}