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:
@@ -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 — {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)
|
||||
|
||||
Reference in New Issue
Block a user