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)
@@ -2197,6 +2197,15 @@
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
<i class="bi bi-question-circle me-1"></i>How it works
</button>
<a href="/CompanySettings/ExportCustomItemTemplates"
class="btn btn-outline-secondary btn-sm"
title="Download all templates as a JSON backup file">
<i class="bi bi-download me-1"></i>Export
</a>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowImport()"
title="Restore templates from a JSON backup file">
<i class="bi bi-upload me-1"></i>Import
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template
</button>
@@ -2281,6 +2290,40 @@
</div>
</div>
<!-- Custom Formula Import Modal -->
<div class="modal fade" id="cfImportModal" tabindex="-1" aria-labelledby="cfImportModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfImportModalLabel">
<i class="bi bi-upload me-2"></i>Import Formula Templates
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Select a <code>.json</code> file previously exported from this page.
Templates whose name already exists in your account will be skipped.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Backup file <span class="text-danger">*</span></label>
<input type="file" id="cfImportFile" class="form-control" accept=".json" />
</div>
<div id="cfImportResults" class="d-none">
<hr />
<div id="cfImportSummary"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="cfImportBtn" onclick="cfSubmitImport()">
<i class="bi bi-upload me-1"></i>Import
</button>
</div>
</div>
</div>
</div>
<!-- Custom Formula Walkthrough Modal -->
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
@@ -4,6 +4,7 @@
(function () {
let cfFields = [];
let cfEditing = false;
let cfFormDirty = false;
// ── Load & Render ─────────────────────────────────────────────────────────
@@ -221,9 +222,11 @@
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
document.getElementById('cfDiagramPreview').style.display = '';
}
cfFormDirty = false;
}
function cfResetForm() {
cfFormDirty = false;
document.getElementById('cfId').value = '0';
document.getElementById('cfName').value = '';
document.getElementById('cfDescription').value = '';
@@ -528,6 +531,7 @@
});
}
cfFormDirty = false;
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
cfLoadTemplates();
} catch (e) {
@@ -862,4 +866,103 @@
cfWtStep = i;
cfRenderWtStep();
};
// ── Import ────────────────────────────────────────────────────────────────
window.cfShowImport = function () {
document.getElementById('cfImportFile').value = '';
document.getElementById('cfImportResults').classList.add('d-none');
document.getElementById('cfImportSummary').innerHTML = '';
const btn = document.getElementById('cfImportBtn');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
new bootstrap.Modal(document.getElementById('cfImportModal')).show();
};
window.cfSubmitImport = async function () {
const fileInput = document.getElementById('cfImportFile');
if (!fileInput.files.length) {
showCfError('Please select a .json export file first.');
return;
}
const btn = document.getElementById('cfImportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Importing&hellip;';
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('__RequestVerificationToken', getAntiForgeryToken());
try {
const res = await fetch('/CompanySettings/ImportCustomItemTemplates', { method: 'POST', body: form });
const data = await res.json();
const resultsEl = document.getElementById('cfImportResults');
const summaryEl = document.getElementById('cfImportSummary');
resultsEl.classList.remove('d-none');
if (!data.success) {
summaryEl.innerHTML = `<div class="alert alert-danger alert-permanent mb-0"><i class="bi bi-x-circle me-2"></i>${escHtml(data.message)}</div>`;
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
return;
}
let html = '';
if (data.imported > 0)
html += `<div class="alert alert-success alert-permanent mb-2"><i class="bi bi-check-circle me-2"></i><strong>${data.imported}</strong> template${data.imported !== 1 ? 's' : ''} imported successfully.</div>`;
if (data.skipped > 0) {
const names = (data.skippedNames || []).map(n => `<li>${escHtml(n)}</li>`).join('');
html += `<div class="alert alert-warning alert-permanent mb-2">
<i class="bi bi-skip-forward me-2"></i><strong>${data.skipped}</strong> skipped &mdash; name already exists:
<ul class="mb-0 mt-1 small">${names}</ul>
</div>`;
}
if (data.errors && data.errors.length) {
const items = data.errors.map(e => `<li>${escHtml(e)}</li>`).join('');
html += `<div class="alert alert-danger alert-permanent mb-2">
<i class="bi bi-exclamation-triangle me-2"></i><strong>${data.errors.length}</strong> error${data.errors.length !== 1 ? 's' : ''}:
<ul class="mb-0 mt-1 small">${items}</ul>
</div>`;
}
if (data.imported === 0 && data.skipped === 0 && (!data.errors || !data.errors.length))
html = '<div class="alert alert-info alert-permanent mb-0"><i class="bi bi-info-circle me-2"></i>The file contained no templates to import.</div>';
summaryEl.innerHTML = html;
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-check me-1"></i>Done';
if (data.imported > 0) cfLoadTemplates();
} catch (e) {
showCfError('Import request failed: ' + e.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Import';
}
};
// ── Unsaved-changes guard ─────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('cfModal');
if (!modal) return;
// Any user interaction inside the modal marks the form dirty
modal.addEventListener('input', function () { cfFormDirty = true; });
modal.addEventListener('change', function () { cfFormDirty = true; });
// Intercept backdrop click, ESC, and the X button when there is unsaved work
modal.addEventListener('hide.bs.modal', function (e) {
if (!cfFormDirty) return;
e.preventDefault();
if (confirm('You have unsaved changes. Close anyway and lose your work?')) {
cfFormDirty = false;
bootstrap.Modal.getInstance(modal)?.hide();
}
});
});
})();