diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index 4e395d3..58b245b 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -3001,6 +3001,9 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data." }); + var fieldError = ValidateTemplateFields(dto.FieldsJson); + if (fieldError != null) return Json(new { success = false, message = fieldError }); + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = _mapper.Map(dto); entity.CompanyId = companyId; @@ -3019,6 +3022,9 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data." }); + var fieldError = ValidateTemplateFields(dto.FieldsJson); + if (fieldError != null) return Json(new { success = false, message = fieldError }); + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id); if (entity == null || entity.CompanyId != companyId) @@ -3136,6 +3142,44 @@ public class CompanySettingsController : Controller return Json(result); } + + /// + /// Validates field variable names in a fieldsJson array against NCalc identifier rules: + /// must start with a letter, contain only letters/digits/underscores, and not use the + /// reserved name "rate" (which is auto-populated from the template's Default Rate). + /// Returns an error message string on failure, or null if all names are valid. + /// + private static string? ValidateTemplateFields(string? fieldsJson) + { + if (string.IsNullOrWhiteSpace(fieldsJson)) return null; + + List? fields; + try + { + fields = System.Text.Json.JsonSerializer.Deserialize>(fieldsJson); + } + catch { return "Invalid fields JSON."; } + + if (fields == null) return null; + + var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$"); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var field in fields) + { + var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : ""; + if (string.IsNullOrEmpty(name)) + return "All fields must have a variable name."; + if (name == "rate") + return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate."; + if (!nameRegex.IsMatch(name)) + return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores."; + if (!seen.Add(name)) + return $"Duplicate field name \"{name}\"."; + } + + return null; + } } public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body); diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 4b079bc..aba9548 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -3340,6 +3340,13 @@ public class JobsController : Controller var useMetric = await _tenantContext.UseMetricSystemAsync(); ViewBag.UseMetric = useMetric; ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); + + var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive); + ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name) + .Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode, + fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel, + diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null + : Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList(); } /// diff --git a/src/PowderCoating.Web/Views/Jobs/Details.cshtml b/src/PowderCoating.Web/Views/Jobs/Details.cshtml index a1d806e..9e498ae 100644 --- a/src/PowderCoating.Web/Views/Jobs/Details.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Details.cshtml @@ -2489,7 +2489,9 @@ "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "itemsFieldPrefix": "JobItems", - "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")" + "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")", + "customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List()), + "formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")" } diff --git a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml index 81a689c..5aa2981 100644 --- a/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/EditItems.cshtml @@ -179,7 +179,9 @@ "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "itemsFieldPrefix": "JobItems", - "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")" + "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")", + "customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List()), + "formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")" } 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 bf26863..6081029 100644 --- a/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js +++ b/src/PowderCoating.Web/wwwroot/js/company-settings-custom-formulas.js @@ -137,6 +137,37 @@ cfRenderFields(); }; + window.cfUpdateField = function (i, key, val, isNumber) { + cfFields[i][key] = isNumber ? (parseFloat(val) || 0) : val; + if (key === 'name') cfValidateFieldNameInput(i, val); + }; + + function cfValidateFieldName(name) { + if (!name) return 'Field variable name is required.'; + if (name === 'rate') return '"rate" is reserved — it is pre-populated from the template\'s Default Rate.'; + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) return 'Must start with a letter and contain only letters, digits, or underscores (no spaces).'; + return null; + } + + function cfValidateFieldNameInput(i, val) { + const inputs = document.querySelectorAll('#cfFieldsList .field-name-input'); + const input = inputs[i]; + if (!input) return; + const err = cfValidateFieldName(val); + input.classList.toggle('is-invalid', !!err); + let fb = input.nextElementSibling; + if (err) { + if (!fb || !fb.classList.contains('invalid-feedback')) { + fb = document.createElement('div'); + fb.className = 'invalid-feedback'; + input.after(fb); + } + fb.textContent = err; + } else if (fb && fb.classList.contains('invalid-feedback')) { + fb.remove(); + } + } + function cfRenderFields() { const el = document.getElementById('cfFieldsList'); if (!cfFields.length) { @@ -147,24 +178,24 @@
- + oninput="cfUpdateField(${i},'name',this.value)" />${cfValidateFieldName(f.name) ? `
${escHtml(cfValidateFieldName(f.name))}
` : ''}
+ oninput="cfUpdateField(${i},'label',this.value)" />
+ oninput="cfUpdateField(${i},'unit',this.value)" />
+ oninput="cfUpdateField(${i},'defaultValue',this.value,true)" />