Add Community Formula Library feature

Companies can now share their custom formula templates to a platform-wide
community library. Other tenants can browse, preview, and import formulas
as independent local copies. Includes attribution (source company name),
"Inspired by" lineage for re-contributed formulas, import counts, own-formula
badge, cascade diagram nullification, and AI assistant + help docs updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 21:54:51 -04:00
parent 32d09b38f1
commit ca7e905832
24 changed files with 12959 additions and 10 deletions
@@ -0,0 +1,164 @@
(function () {
'use strict';
const importModal = new bootstrap.Modal(document.getElementById('importModal'));
let currentLibraryItemId = null;
// Open preview modal when any "Preview & Import" button is clicked
document.getElementById('libraryGrid')?.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-import');
if (!btn) return;
currentLibraryItemId = parseInt(btn.dataset.itemId, 10);
const itemName = btn.dataset.itemName;
document.getElementById('importModalLabel').textContent = 'Import — ' + itemName;
document.getElementById('importModalBody').innerHTML =
'<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div>' +
'<p class="mt-2 text-muted">Loading formula details…</p></div>';
document.getElementById('btnConfirmImport').disabled = true;
importModal.show();
fetch('/FormulaLibrary/Detail/' + currentLibraryItemId)
.then(r => r.json())
.then(renderDetail)
.catch(() => {
document.getElementById('importModalBody').innerHTML =
'<div class="alert alert-danger">Failed to load formula details.</div>';
});
});
function renderDetail(d) {
let fields = [];
try { fields = JSON.parse(d.fieldsJson || '[]'); } catch (_) { }
const alreadyBadge = d.alreadyImported
? '<span class="badge bg-success ms-2"><i class="bi bi-check-lg me-1"></i>Already in your library</span>'
: '';
const inspiredRow = (d.inspiredByName)
? `<div class="alert alert-light border fst-italic small py-2 mb-3">
<i class="bi bi-diagram-2 me-1"></i>Inspired by
&ldquo;${escHtml(d.inspiredByName)}&rdquo; from ${escHtml(d.inspiredByCompanyName)}
</div>`
: '';
const modeBadge = d.outputMode === 'FixedRate'
? '<span class="badge bg-primary">Fixed Rate</span>'
: '<span class="badge bg-info">Surface Area (sq ft)</span>';
const fieldRows = fields.map(f =>
`<tr><td>${escHtml(f.label || f.name)}</td><td class="text-muted">${escHtml(f.unit || '')}</td><td>${escHtml(String(f.defaultValue ?? ''))}</td></tr>`
).join('');
const diagramHtml = d.diagramImagePath
? `<div class="mb-3"><img src="/FormulaLibrary/Diagram?path=${encodeURIComponent(d.diagramImagePath)}" class="img-fluid rounded border" style="max-height:200px" alt="Formula diagram" /></div>`
: '';
document.getElementById('importModalBody').innerHTML = `
<div class="row">
<div class="col-md-6">
<p class="mb-1"><strong>${escHtml(d.name)}</strong>${alreadyBadge}</p>
<p class="text-muted small mb-2"><i class="bi bi-building me-1"></i>${escHtml(d.sourceCompanyName)}</p>
${inspiredRow}
${d.description ? `<p class="text-muted small mb-3">${escHtml(d.description)}</p>` : ''}
<div class="d-flex gap-2 mb-3">
${modeBadge}
${d.industryHint ? `<span class="badge bg-secondary">${escHtml(d.industryHint)}</span>` : ''}
</div>
${d.defaultRate != null ? `<p class="small mb-1"><strong>Default rate:</strong> ${escHtml(String(d.defaultRate))} ${escHtml(d.rateLabel || '')}</p>` : ''}
${d.notes ? `<p class="small text-muted">${escHtml(d.notes)}</p>` : ''}
</div>
<div class="col-md-6">
${diagramHtml}
${fields.length > 0 ? `
<h6 class="small fw-semibold mb-2">Input Fields (${fields.length})</h6>
<table class="table table-sm table-bordered">
<thead><tr><th>Field</th><th>Unit</th><th>Default</th></tr></thead>
<tbody>${fieldRows}</tbody>
</table>` : '<p class="text-muted small">No fields defined.</p>'}
<div class="mt-2">
<h6 class="small fw-semibold mb-1">Formula Expression</h6>
<code class="d-block bg-light border rounded p-2 small text-break">${escHtml(d.formula)}</code>
</div>
</div>
</div>`;
const importBtn = document.getElementById('btnConfirmImport');
if (d.alreadyImported) {
importBtn.disabled = true;
importBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Already Imported';
importBtn.classList.replace('btn-primary', 'btn-success');
} else {
importBtn.disabled = false;
}
}
// Confirm import
document.getElementById('btnConfirmImport')?.addEventListener('click', function () {
if (!currentLibraryItemId) return;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Importing&hellip;';
const form = new FormData();
form.append('libraryItemId', currentLibraryItemId);
form.append('__RequestVerificationToken', document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '');
fetch('/FormulaLibrary/Import', { method: 'POST', body: form })
.then(r => r.json())
.then(res => {
if (res.success) {
importModal.hide();
showToast('Formula imported to your library!', 'success');
// Mark button on the card
const card = document.querySelector(`.btn-import[data-item-id="${currentLibraryItemId}"]`);
if (card) {
card.classList.replace('btn-outline-primary', 'btn-outline-success');
card.innerHTML = '<i class="bi bi-check-lg me-1"></i><span>Already Imported</span>';
card.disabled = true;
card.closest('.card')?.classList.add('border-start', 'border-success', 'border-3');
}
} else {
showToast(res.message || 'Import failed.', 'danger');
this.disabled = false;
this.innerHTML = '<i class="bi bi-cloud-download me-1"></i>Import to My Formulas';
}
})
.catch(() => {
showToast('Import failed. Please try again.', 'danger');
this.disabled = false;
this.innerHTML = '<i class="bi bi-cloud-download me-1"></i>Import to My Formulas';
});
});
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function showToast(msg, type) {
const container = document.getElementById('toastContainer')
|| (() => {
const c = document.createElement('div');
c.id = 'toastContainer';
c.className = 'toast-container position-fixed bottom-0 end-0 p-3';
c.style.zIndex = '1100';
document.body.appendChild(c);
return c;
})();
const el = document.createElement('div');
el.className = `toast align-items-center text-white bg-${type} border-0`;
el.setAttribute('role', 'alert');
el.innerHTML = `<div class="d-flex"><div class="toast-body">${escHtml(msg)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(el);
new bootstrap.Toast(el, { delay: 4000 }).show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
})();