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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 09:24:02 -04:00
parent cf07356147
commit b23bea6db0
3 changed files with 277 additions and 0 deletions
@@ -3043,6 +3043,137 @@ public class CompanySettingsController : Controller
return Json(new { success = true, templates = dtos });
}
/// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
[HttpGet]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<string>();
var errors = new List<string>();
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 &mdash; {formulaError}"); continue; }
dto.Formula = normalizedFormula;
var entity = _mapper.Map<CustomItemTemplate>(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 });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)