From d28e639d1b2019233353a7b7be9023f44ee63bf7 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 24 May 2026 11:40:54 -0400 Subject: [PATCH] Add formula template walkthrough and UX improvements - 7-step guided walkthrough modal (concept, output modes, fields, formula, testing, box example, cylinder example); auto-shown first time the tab opens with no templates; always accessible via "How it works" button - Variable pill badges below formula input showing all valid field names + rate; update live as fields are added/renamed; clickable to insert at cursor - Fix: Add Field no longer shows validation error on blank new rows; validation only fires once the user has typed something - Help article: added Common Formula Patterns section with box, cylinder, and flat panel worked examples (fields table + formula + expected output) - HelpKnowledgeBase updated with pattern examples and walkthrough note Co-Authored-By: Claude Sonnet 4.6 --- .../Helpers/HelpKnowledgeBase.cs | 6 +- .../Views/CompanySettings/Index.cshtml | 57 +++- .../Views/Help/CustomFormulaTemplates.cshtml | 56 +++- .../js/company-settings-custom-formulas.js | 313 +++++++++++++++++- 4 files changed, 415 insertions(+), 17 deletions(-) diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 03f722d..2dc8421 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -1401,7 +1401,11 @@ public static class HelpKnowledgeBase Using in wizard: item wizard shows "Custom Formula Item" card if active templates exist → choose template → template diagram shown for reference → enter measurements → Calculate → verify result → continue to coatings/prep steps Formula variable names: snake_case, letters/digits/underscores only. Reserved variable: "rate" (pre-populated from Default Rate). NCalc syntax: +, -, *, /, %, Pow(b,e), Abs(x), Round(x,d), Max(a,b), Min(a,b), Sqrt(x) - Example formula (box, inches): 2*(l*w + l*h + w*h) / 144 * rate + Common formula patterns (all Fixed Rate, divide inches by 144 to get sqft): + - 6-sided box: fields l_in/w_in/h_in → 2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate + - Cylinder: fields d_in/h_in → (3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate + - Flat panel: fields l_in/w_in → l_in * w_in / 144 * rate + Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button Help article: Help → Custom Formula Item Templates """; diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml index f9e02e1..92a32ce 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml @@ -2064,9 +2064,14 @@
Custom Formula Item Templates
- +
+ + +

@@ -2097,6 +2102,34 @@

+ + + diff --git a/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js b/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js index 6081029..521a84a 100644 --- a/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js +++ b/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js @@ -139,7 +139,7 @@ window.cfUpdateField = function (i, key, val, isNumber) { cfFields[i][key] = isNumber ? (parseFloat(val) || 0) : val; - if (key === 'name') cfValidateFieldNameInput(i, val); + if (key === 'name') { cfValidateFieldNameInput(i, val); cfRenderVariablePills(); } }; function cfValidateFieldName(name) { @@ -174,13 +174,14 @@ el.innerHTML = '

No fields yet.

'; return; } + cfRenderVariablePills(); el.innerHTML = cfFields.map((f, i) => `
- ${cfValidateFieldName(f.name) ? `
${escHtml(cfValidateFieldName(f.name))}
` : ''} + oninput="cfUpdateField(${i},'name',this.value)" />${f.name && cfValidateFieldName(f.name) ? `
${escHtml(cfValidateFieldName(f.name))}
` : ''}
`).join(''); } + function cfRenderVariablePills() { + const container = document.getElementById('cfVariablePills'); + if (!container) return; + const names = cfFields.map(f => f.name).filter(n => n && !cfValidateFieldName(n)); + const all = [...names, 'rate']; + container.innerHTML = all.map(n => + `${escHtml(n)}` + ).join(''); + } + + window.cfInsertVariable = function (name) { + const input = document.getElementById('cfFormula'); + if (!input) return; + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + const before = input.value.slice(0, start); + const after = input.value.slice(end); + const needsSpace = before.length > 0 && !/[\s(+\-*/]$/.test(before); + const insert = (needsSpace ? ' ' : '') + name; + input.value = before + insert + after; + const cursor = start + insert.length; + input.setSelectionRange(cursor, cursor); + input.focus(); + }; + // ── Formula Test ────────────────────────────────────────────────────────── window.cfTestFormula = async function () { @@ -421,4 +448,284 @@ function getAntiForgeryToken() { return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; } + + // ── Walkthrough ─────────────────────────────────────────────────────────── + + let cfWtStep = 0; + + const cfWtSteps = [ + { + title: 'What are Formula Templates?', + icon: 'bi-lightbulb text-warning', + html: ` +

Some items — roof curbs, electrical enclosures, welded frames — have prices that depend on + exact measurements rather than estimated surface area.

+

A Formula Template lets you define a reusable calculation once in Company Settings. + When a staff member adds that item to a quote or job, they just fill in the measurements and the price + calculates automatically — no mental math, no spreadsheets.

+
+ + Templates appear as a "Custom Formula Item" option in the quote and job item wizard, + but only when at least one active template exists. +
` + }, + { + title: 'Output Modes', + icon: 'bi-toggle2-on text-primary', + html: ` +

When you create a template, you choose how the formula result is used:

+
+ + + + + + + + + + + + + + + + +
ModeFormula produces…How it’s priced
Fixed RateA dollar amountUsed directly as the item’s unit price. Best for items priced by a custom rate (e.g. $/sqft of surface).
Surface AreaSquare footageFed into the standard coating engine and priced using your operating cost rates — just like any other coated item.
+
+
+ + Most shops start with Fixed Rate. It gives you full control over the per-unit price + and is the easiest to reason about. +
` + }, + { + title: 'Step 1 — Define Your Fields', + icon: 'bi-input-cursor-text text-success', + html: ` +

Fields are the measurements your staff will fill in when they use the template. + Each field becomes an input box in the item wizard.

+
+
+
+
What you set up
+
+ + + + +
Variable namelength_in
LabelLength (inches)
Default24
+ + + + +
Variable namewidth_in
LabelWidth (inches)
Default12
+
+
+
+
+
+
What staff sees in the wizard
+
+ + + + +
+
+
+
+
+ + Variable names must start with a letter and contain only letters, digits, or underscores — no spaces. + Good: length_in   Bad: length in or 1length +
` + }, + { + title: 'Step 2 — Write the Formula', + icon: 'bi-braces text-info', + html: ` +

The formula is a math expression using your field variable names plus the reserved + variable rate (pre-filled from the template’s Default Rate).

+

Example — flat panel price (inches → sqft → dollars):

+
length_in * width_in / 144 * rate
+

Supported operations:

+
+
+
    +
  • +  -  *  / — basic math
  • +
  • Pow(base, exp) — exponents
  • +
  • Sqrt(x) — square root
  • +
+
+
+
    +
  • Round(x, digits) — rounding
  • +
  • Abs(x) — absolute value
  • +
  • Max(a,b)  Min(a,b)
  • +
+
+
+
+ + rate is always available — you set it as the Default Rate on the template + and staff can override it per-use. Don’t create a field called rate. +
` + }, + { + title: 'Step 3 — Test Before Saving', + icon: 'bi-play-circle text-success', + html: ` +

Before saving, use the Run button to verify your formula evaluates correctly + using each field’s default value.

+
+
+
+
+ + +
+
+ +
+
+  = 7.0000 +
+
+
Using defaults: length_in=24, width_in=12, rate=3.50 → 24×12/144×3.50 = $7.00
+
+
+

If the formula has an error (typo, missing variable, bad syntax) the result will + show in red with a description of the problem. Fix it before saving.

` + }, + { + title: 'Example — 6-Sided Box', + icon: 'bi-box text-secondary', + html: ` +

A roof curb or electrical enclosure can be priced by calculating its outer surface area.

+
+
Template Setup
+
+
+
+ Fields + + + + + + + +
VariableLabelDefault
l_inLength (in)24
w_inWidth (in)24
h_inHeight (in)12
+
+
+ Formula (Fixed Rate, rate = 3.50) +
2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate
+ What it does: Calculates total outer surface area of all 6 faces + in square inches, converts to square feet (÷144), multiplies by rate. +
24×24×12 box at $3.50/sqft → $28.00
+
+
+
+
+

+ + You can add a diagram image to the template — staff will see it in the wizard as a reference + while entering measurements. +

` + }, + { + title: 'Example — Cylinder', + icon: 'bi-vinyl text-secondary', + html: ` +

Round parts like pipe ends or cylindrical housings use a slightly different formula.

+
+
Template Setup
+
+
+
+ Fields + + + + + + +
VariableLabelDefault
d_inDiameter (in)12
h_inHeight (in)18
+
+
+ Formula (Fixed Rate, rate = 3.50) +
(3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate
+ What it does: Lateral surface area (circumference × height) + plus two circular end caps, converted to sqft, multiplied by rate. +
12″ dia × 18″ tall at $3.50/sqft → ~$10.21
+
+
+
+
+
+ + You’re ready to create your first template! + Click Get Started below to open the template editor, or close this guide + and click New Template any time. +
` + } + ]; + + window.cfShowWalkthrough = function () { + cfWtStep = 0; + cfRenderWtStep(); + new bootstrap.Modal(document.getElementById('cfWalkthroughModal')).show(); + localStorage.setItem('cfWalkthroughSeen', '1'); + }; + + window.cfWalkthroughNav = function (dir) { + const next = cfWtStep + dir; + if (next < 0) return; + if (next >= cfWtSteps.length) { + bootstrap.Modal.getInstance(document.getElementById('cfWalkthroughModal'))?.hide(); + cfShowCreate(); + return; + } + cfWtStep = next; + cfRenderWtStep(); + }; + + function cfRenderWtStep() { + const step = cfWtSteps[cfWtStep]; + const total = cfWtSteps.length; + const isLast = cfWtStep === total - 1; + + // Dots + document.getElementById('cfWalkthroughDots').innerHTML = cfWtSteps.map((_, i) => + `` + ).join(''); + + // Content + document.getElementById('cfWalkthroughContent').innerHTML = ` +
+ +
${step.title}
+ ${cfWtStep + 1} of ${total} +
+ ${step.html}`; + + // Buttons + document.getElementById('cfWtPrevBtn').style.visibility = cfWtStep === 0 ? 'hidden' : 'visible'; + const nextBtn = document.getElementById('cfWtNextBtn'); + if (isLast) { + nextBtn.innerHTML = 'Get Started'; + nextBtn.className = 'btn btn-success'; + } else { + nextBtn.innerHTML = 'Next'; + nextBtn.className = 'btn btn-primary'; + } + } + + window.cfWtJump = function (i) { + cfWtStep = i; + cfRenderWtStep(); + }; })();