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
@@ -9,14 +9,15 @@
window.cfLoadTemplates = async function () {
const tbody = document.getElementById('cfTemplatesBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">Loading&hellip;</td></tr>';
try {
const res = await fetch('/CompanySettings/GetCustomItemTemplates');
const data = await res.json();
if (!data.success || !data.templates.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">No formula templates yet. Click <strong>New Template</strong> to create one.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">No formula templates yet. Click <strong>New Template</strong> to create one.</td></tr>';
return;
}
// Render rows first, then load library status per row asynchronously
tbody.innerHTML = data.templates.map(t => `
<tr>
<td>
@@ -32,6 +33,7 @@
<td>${t.isActive
? '<span class="badge bg-success">Active</span>'
: '<span class="badge bg-secondary">Inactive</span>'}</td>
<td id="cfLibStatus_${t.id}"><span class="text-muted small">&hellip;</span></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="cfShowEdit(${t.id})">
<i class="bi bi-pencil"></i>
@@ -41,11 +43,125 @@
</button>
</td>
</tr>`).join('');
// Load library status for each template (non-blocking)
data.templates.forEach(t => cfLoadLibraryStatus(t.id));
} catch (e) {
tbody.innerHTML = '<tr><td colspan="5" class="text-danger text-center py-3">Failed to load templates.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-3">Failed to load templates.</td></tr>';
}
};
// ── Community Library: share / unshare ────────────────────────────────────
async function cfLoadLibraryStatus(templateId) {
const cell = document.getElementById(`cfLibStatus_${templateId}`);
if (!cell) return;
try {
const res = await fetch(`/CompanySettings/FormulaLibraryStatus?templateId=${templateId}`);
const s = await res.json();
cell.innerHTML = cfLibraryStatusHtml(templateId, s);
} catch (_) {
cell.innerHTML = '';
}
}
function cfLibraryStatusHtml(templateId, s) {
if (s.isPublished) {
return `<span class="badge bg-info-subtle text-info border border-info-subtle me-1">In Library</span>
<button class="btn btn-sm btn-outline-secondary" onclick="cfUnshare(${templateId}, ${s.libraryItemId})" title="Remove from community library">
<i class="bi bi-cloud-slash"></i>
</button>`;
}
if (!s.canShare) {
// Imported but not modified — show attribution only
if (s.importedFromName) {
return `<small class="text-muted" title="Imported from community library">
<i class="bi bi-cloud-download me-1"></i>${escHtml(s.importedFromName)}
</small>`;
}
return '';
}
// Eligible to share
const inspiredNote = s.importedFromName ? ` (inspired by ${escHtml(s.importedFromName)})` : '';
return `<button class="btn btn-sm btn-outline-info" onclick="cfShowShare(${templateId}, ${!!s.importedFromName})"
title="Share to community library${inspiredNote}">
<i class="bi bi-collection me-1"></i>Share
</button>`;
}
window.cfShowShare = function (templateId, isInspired) {
const modal = document.getElementById('cfShareModal');
if (!modal) {
// Modal not in DOM — page is likely cached. Ask user to hard-refresh.
alert('Share dialog not found. Please press Ctrl+F5 (or Cmd+Shift+R on Mac) to reload the page, then try again.');
return;
}
document.getElementById('cfShareTemplateId').value = templateId;
document.getElementById('cfShareTags').value = '';
document.getElementById('cfShareIndustryHint').value = '';
const inspiredEl = document.getElementById('cfShareInspiredBy');
if (inspiredEl) inspiredEl.style.display = isInspired ? '' : 'none';
const confirmBtn = document.getElementById('cfShareConfirmBtn');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-collection me-1"></i>Share to Library';
}
new bootstrap.Modal(modal).show();
};
window.cfConfirmShare = async function () {
const templateId = parseInt(document.getElementById('cfShareTemplateId').value, 10);
const btn = document.getElementById('cfShareConfirmBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sharing&hellip;';
const payload = {
customItemTemplateId: templateId,
tags: document.getElementById('cfShareTags').value.trim() || null,
industryHint: document.getElementById('cfShareIndustryHint').value.trim() || null,
};
try {
const res = await fetch('/CompanySettings/ShareFormula', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getCsrfToken() },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('cfShareModal')).hide();
cfLoadLibraryStatus(templateId);
} else {
alert(data.message || 'Failed to share formula.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-collection me-1"></i>Share to Library';
}
} catch (_) {
alert('An error occurred. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-collection me-1"></i>Share to Library';
}
};
window.cfUnshare = async function (templateId, libraryItemId) {
if (!confirm('Remove this formula from the Community Library? Anyone who has already imported it will keep their copy.')) return;
try {
const form = new FormData();
form.append('libraryItemId', libraryItemId);
form.append('__RequestVerificationToken', getCsrfToken());
const res = await fetch('/CompanySettings/UnshareFormula', { method: 'POST', body: form });
const data = await res.json();
if (data.success) cfLoadLibraryStatus(templateId);
else alert(data.message || 'Failed to remove from library.');
} catch (_) {
alert('An error occurred. Please try again.');
}
};
function getCsrfToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
// ── Create / Edit Modal ───────────────────────────────────────────────────
window.cfShowCreate = function () {
@@ -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());
}
})();