From b23bea6db00f60a6a810f6232cd69914060872a5 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 2 Jun 2026 09:24:02 -0400 Subject: [PATCH] Add formula template export/import and unsaved-changes guard - Export: GET /CompanySettings/ExportCustomItemTemplates downloads all company templates as an indented JSON backup (strips internal IDs/paths) - Import: POST /CompanySettings/ImportCustomItemTemplates restores from that file; runs full field + formula validation, skips name duplicates, returns per-item results (imported / skipped / errors) - Unsaved-changes guard: cfModal now intercepts backdrop/ESC/X when the form is dirty and prompts before discarding work - Export and Import buttons added to the Custom Formulas card header Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/CompanySettingsController.cs | 131 ++++++++++++++++++ .../Views/CompanySettings/Index.cshtml | 43 ++++++ .../js/company-settings-custom-formulas.js | 103 ++++++++++++++ 3 files changed, 277 insertions(+) diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index f791066..d9224cb 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -3043,6 +3043,137 @@ public class CompanySettingsController : Controller return Json(new { success = true, templates = dtos }); } + /// Downloads all formula templates as a portable JSON backup file. + [HttpGet] + public async Task ExportCustomItemTemplates() + { + if (!AllowCustomFormulas()) return Forbid(); + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; + var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId); + + var export = new + { + exportedAt = DateTime.UtcNow, + version = 1, + templates = templates + .OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name) + .Select(t => new + { + t.Name, + t.Description, + t.OutputMode, + t.FieldsJson, + t.Formula, + t.DefaultRate, + t.RateLabel, + t.Notes, + t.DisplayOrder, + t.IsActive + }) + }; + + var json = System.Text.Json.JsonSerializer.Serialize(export, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json"; + return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename); + } + + /// + /// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates. + /// Templates whose name already exists in the company are skipped; all others are created. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ImportCustomItemTemplates(IFormFile file) + { + if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); + if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." }); + if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + return Json(new { success = false, message = "File must be a .json export file." }); + if (file.Length > 512 * 1024) + return Json(new { success = false, message = "File is too large (max 512 KB)." }); + + string json; + using (var reader = new System.IO.StreamReader(file.OpenReadStream())) + json = await reader.ReadToEndAsync(); + + System.Text.Json.JsonElement root; + try + { + root = System.Text.Json.JsonDocument.Parse(json).RootElement; + } + catch + { + return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." }); + } + + if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array) + return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." }); + + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; + var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId); + // Track names already in DB + names imported within this same file to prevent intra-file duplicates + var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet(); + + int imported = 0, skipped = 0; + var skippedNames = new List(); + var errors = new List(); + + foreach (var item in templatesEl.EnumerateArray()) + { + try + { + var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; } + + if (usedNames.Contains(name.ToLowerInvariant())) + { + skipped++; + skippedNames.Add(name); + continue; + } + + var dto = new CreateCustomItemTemplateDto + { + Name = name, + Description = item.TryGetProperty("description", out var d) ? d.GetString() : null, + OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate", + FieldsJson = item.TryGetProperty("fieldsJson", out var fj) ? fj.GetString() ?? "[]" : "[]", + Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "", + DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null, + RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null, + Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null, + DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0, + IsActive = item.TryGetProperty("isActive", out var ia) && ia.ValueKind == System.Text.Json.JsonValueKind.True, + }; + + var fieldError = ValidateTemplateFields(dto.FieldsJson); + if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; } + + var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); + if (formulaError != null) { errors.Add($"\"{name}\": formula error — {formulaError}"); continue; } + dto.Formula = normalizedFormula; + + var entity = _mapper.Map(dto); + entity.CompanyId = companyId; + entity.CreatedAt = DateTime.UtcNow; + await _unitOfWork.CustomItemTemplates.AddAsync(entity); + + usedNames.Add(name.ToLowerInvariant()); + imported++; + } + catch (Exception ex) + { + errors.Add($"Unexpected error on one template: {ex.Message}"); + } + } + + if (imported > 0) + await _unitOfWork.CompleteAsync(); + + return Json(new { success = true, imported, skipped, skippedNames, errors }); + } + /// Creates a new formula template for the current company. [HttpPost] public async Task CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto) diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml index 278f02d..1151807 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml @@ -2197,6 +2197,15 @@ + + Export + + @@ -2281,6 +2290,40 @@ + + +