Compare commits

...

3 Commits

Author SHA1 Message Date
spouliot cd4c233b60 Fix formula export casing: use camelCase to match import property lookups
System.Text.Json defaults to PascalCase for anonymous types, producing
"Name"/"OutputMode" etc., while the import used TryGetProperty("name")
causing every template to fail with "no name". Adding CamelCase naming
policy aligns the export format with what the import expects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:52:58 -04:00
spouliot 6c07216c64 Fix custom formula item pricing: multiply by quantity, not divide
ManualUnitPrice holds the per-item formula result. The previous code
incorrectly treated it as the batch total and divided by Quantity,
causing the unit price to shrink as quantity increased. Now follows
the same pattern as every other ManualUnitPrice path in this method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:27:11 -04:00
spouliot b23bea6db0 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>
2026-06-02 09:24:02 -04:00
4 changed files with 285 additions and 5 deletions
@@ -299,15 +299,14 @@ public class PricingCalculationService : IPricingCalculationService
}
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
// exactly like every other item type that uses ManualUnitPrice.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{
var formulaTotal = item.ManualUnitPrice.Value;
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
var formulaUnitPrice = item.ManualUnitPrice.Value;
var formulaTotal = formulaUnitPrice * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
@@ -3043,6 +3043,141 @@ 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,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
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();
}
});
});
})();