Compare commits
3 Commits
cf07356147
...
cd4c233b60
| Author | SHA1 | Date | |
|---|---|---|---|
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 |
@@ -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 — {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…';
|
||||
|
||||
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 — 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user