Compare commits
3 Commits
| 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
|
// 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
|
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||||
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
|
// exactly like every other item type that uses ManualUnitPrice.
|
||||||
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
|
|
||||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
// 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.
|
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||||
{
|
{
|
||||||
var formulaTotal = item.ManualUnitPrice.Value;
|
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||||
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
|
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||||
return new QuoteItemPricingResult
|
return new QuoteItemPricingResult
|
||||||
{
|
{
|
||||||
MaterialCost = 0,
|
MaterialCost = 0,
|
||||||
|
|||||||
@@ -3043,6 +3043,141 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = true, templates = dtos });
|
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>
|
/// <summary>Creates a new formula template for the current company.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||||
|
|||||||
@@ -2197,6 +2197,15 @@
|
|||||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
|
||||||
<i class="bi bi-question-circle me-1"></i>How it works
|
<i class="bi bi-question-circle me-1"></i>How it works
|
||||||
</button>
|
</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()">
|
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
|
||||||
<i class="bi bi-plus-circle me-1"></i>New Template
|
<i class="bi bi-plus-circle me-1"></i>New Template
|
||||||
</button>
|
</button>
|
||||||
@@ -2281,6 +2290,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Custom Formula Walkthrough Modal -->
|
||||||
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
|
<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">
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
let cfFields = [];
|
let cfFields = [];
|
||||||
let cfEditing = false;
|
let cfEditing = false;
|
||||||
|
let cfFormDirty = false;
|
||||||
|
|
||||||
// ── Load & Render ─────────────────────────────────────────────────────────
|
// ── Load & Render ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -221,9 +222,11 @@
|
|||||||
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
|
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
|
||||||
document.getElementById('cfDiagramPreview').style.display = '';
|
document.getElementById('cfDiagramPreview').style.display = '';
|
||||||
}
|
}
|
||||||
|
cfFormDirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cfResetForm() {
|
function cfResetForm() {
|
||||||
|
cfFormDirty = false;
|
||||||
document.getElementById('cfId').value = '0';
|
document.getElementById('cfId').value = '0';
|
||||||
document.getElementById('cfName').value = '';
|
document.getElementById('cfName').value = '';
|
||||||
document.getElementById('cfDescription').value = '';
|
document.getElementById('cfDescription').value = '';
|
||||||
@@ -528,6 +531,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfFormDirty = false;
|
||||||
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
|
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
|
||||||
cfLoadTemplates();
|
cfLoadTemplates();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -862,4 +866,103 @@
|
|||||||
cfWtStep = i;
|
cfWtStep = i;
|
||||||
cfRenderWtStep();
|
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