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
@@ -35,6 +35,7 @@ public class CompanySettingsController : Controller
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
private readonly IFormulaLibraryService _formulaLibraryService;
public CompanySettingsController(
IUnitOfWork unitOfWork,
@@ -49,7 +50,8 @@ public class CompanySettingsController : Controller
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService)
ICustomFormulaAiService formulaAiService,
IFormulaLibraryService formulaLibraryService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -64,6 +66,7 @@ public class CompanySettingsController : Controller
_signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
_formulaLibraryService = formulaLibraryService;
}
/// <summary>
@@ -3080,6 +3083,11 @@ public class CompanySettingsController : Controller
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
// If this was imported from the library, mark it as modified so the share button appears
if (entity.SourceFormulaLibraryItemId.HasValue)
entity.IsModifiedFromSource = true;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
@@ -3100,6 +3108,52 @@ public class CompanySettingsController : Controller
return Json(new { success = true });
}
// ── Community Library: share / unshare / status ───────────────────────
/// <summary>
/// Returns the community library status for a given template: whether it is published,
/// eligible to share, and where it was originally imported from if applicable.
/// </summary>
[HttpGet]
public async Task<IActionResult> FormulaLibraryStatus(int templateId)
{
if (!AllowCustomFormulas()) return Json(new { canShare = false });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
return Json(status);
}
/// <summary>
/// Publishes a company template to the community library (or re-publishes after unshare).
/// Only templates that are original creations or modified imports may be shared.
/// </summary>
[HttpPost]
public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
try
{
var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
return Json(new { success = true, libraryItemId });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
[HttpPost]
public async Task<IActionResult> UnshareFormula(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
return Json(new { success = true });
}
/// <summary>
/// Uploads a diagram image for a template to blob storage container
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
@@ -0,0 +1,109 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Community formula library — browse published formulas from all companies and import
/// them into the current company's local template list.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class FormulaLibraryController : Controller
{
private readonly IFormulaLibraryService _libraryService;
private readonly ITenantContext _tenantContext;
private readonly IMapper _mapper;
private readonly IAzureBlobStorageService _blobStorage;
public FormulaLibraryController(
IFormulaLibraryService libraryService,
ITenantContext tenantContext,
IMapper mapper,
IAzureBlobStorageService blobStorage)
{
_libraryService = libraryService;
_tenantContext = tenantContext;
_mapper = mapper;
_blobStorage = blobStorage;
}
/// <summary>Browse the community library with optional search and filter params.</summary>
// GET: /FormulaLibrary
public async Task<IActionResult> Index(
string? search = null,
string? outputMode = null,
string? industryHint = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction("Index", "Home");
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
ViewBag.Search = search;
ViewBag.OutputMode = outputMode;
ViewBag.IndustryHint = industryHint;
ViewBag.TotalCount = items.Count();
return View(items);
}
/// <summary>Returns full detail JSON for the import preview modal.</summary>
// GET: /FormulaLibrary/Detail/5
[HttpGet]
public async Task<IActionResult> Detail(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { error = "No company context." });
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
if (detail == null) return NotFound();
return Json(detail);
}
/// <summary>
/// Serves a formula diagram image by blob storage path. Used for library cards where the
/// diagram belongs to another company's template blob container.
/// </summary>
// GET: /FormulaLibrary/Diagram?path=...
[HttpGet]
public async Task<IActionResult> Diagram(string path)
{
if (string.IsNullOrWhiteSpace(path)) return NotFound();
// Sanitize: path must not escape the blob container
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
return BadRequest();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>Imports a library entry as a new local template for the current company.</summary>
// POST: /FormulaLibrary/Import
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Import(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "No company context." });
try
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
return Json(new { success = true, templateId });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
}
@@ -1436,6 +1436,15 @@ public static class HelpKnowledgeBase
Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button
Help article: Help Custom Formula Item Templates
**Community Formula Library (Company Settings Custom Formulas Community Library button):**
Platform-wide library where companies share their custom formula templates with all Powder Coating Logix users.
- Sharing: in the Library column on the Custom Formulas tab, click Share add optional Tags and Industry Hint Publish to Library. Eligible templates: ones created from scratch, or imported templates the company has since modified. Unmodified copies of another company's formula cannot be re-shared.
- Browsing: open via Community Library button on Custom Formulas tab search by name/description/tags, filter by Output Mode or Industry click Preview & Import to see full fields, formula expression, and diagram.
- Importing: click Import to My Formulas in the preview modal a fully independent copy is added to your local library; edits to the copy do not affect the original. If the original creator deletes their diagram image, the image is automatically cleared from all imported copies.
- Attribution: every card shows the source company name. If a company imports a formula, modifies it, and re-shares it, the card displays "Inspired by [original name] from [original company]".
- Your own shared formulas: appear in the library with a gold "Your Formula" badge; Manage button links back to Company Settings. To remove from the library, click Unshare in the Library column existing imports are unaffected.
- Import counts are shown on each card and the library is sorted by popularity (most imported first).
---
**Employee Timeclock (/Timeclock):**
+2
View File
@@ -220,6 +220,7 @@ builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
@@ -294,6 +295,7 @@ cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new PurchaseOrderProfile());
cfg.AddProfile(new PricingTierProfile());
cfg.AddProfile(new CustomItemTemplateProfile());
cfg.AddProfile(new FormulaLibraryProfile());
}, loggerFactory);
return config.CreateMapper();
});
@@ -2177,6 +2177,10 @@
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Custom Formula Item Templates</h5>
<div class="d-flex gap-2">
<a asp-controller="FormulaLibrary" asp-action="Index"
class="btn btn-outline-info btn-sm">
<i class="bi bi-collection me-1"></i>Community Library
</a>
<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>
@@ -2190,6 +2194,8 @@
Define reusable pricing formulas for complex fabricated items (roof curbs, enclosures, frames).
When a user adds a formula item to a quote or job, they fill in the measurements and the formula
calculates the price automatically.
Browse the <a asp-controller="FormulaLibrary" asp-action="Index">Community Library</a> to import
formulas shared by other shops.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="cfTemplatesTable">
@@ -2199,11 +2205,12 @@
<th>Output Mode</th>
<th>Fields</th>
<th>Active</th>
<th>Library</th>
<th></th>
</tr>
</thead>
<tbody id="cfTemplatesBody">
<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>
<tr><td colspan="6" class="text-muted text-center py-3">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
@@ -2212,6 +2219,52 @@
</div>
}
@* Share modal lives inside the AllowCustomFormulas block so it is always in the DOM
when the Share button can appear — prevents stale-cache mismatches. *@
@if (ViewBag.AllowCustomFormulas == true)
{
<div class="modal fade" id="cfShareModal" tabindex="-1" aria-labelledby="cfShareModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfShareModalLabel">
<i class="bi bi-collection me-2 text-info"></i>Share to Community Library
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfShareTemplateId" value="0" />
<p class="text-muted small mb-3">
Your formula will be visible to all Powder Coating Logix users and can be imported
into their local library. You can remove it from the community library at any time &mdash;
anyone who has already imported it will keep their copy.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Tags <small class="text-muted">(optional, comma-separated)</small></label>
<input type="text" class="form-control" id="cfShareTags"
placeholder="e.g. HVAC, Sheet Metal, Enclosures" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Industry Hint <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" id="cfShareIndustryHint"
placeholder="e.g. HVAC, Automotive, Structural" />
</div>
<div id="cfShareInspiredBy" class="alert alert-light border fst-italic small py-2" style="display:none">
<i class="bi bi-diagram-2 me-1"></i>
This formula will be listed as &ldquo;Inspired by&rdquo; the original community entry.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-info text-white" id="cfShareConfirmBtn" onclick="cfConfirmShare()">
<i class="bi bi-collection me-1"></i>Share to Library
</button>
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -2283,7 +2336,9 @@
</div>
<div class="mb-3">
<label class="form-label">Formula <span class="text-danger">*</span></label>
<input type="text" id="cfFormula" class="form-control font-monospace" placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate" />
<textarea id="cfFormula" class="form-control font-monospace" rows="3"
style="resize:vertical;min-height:4rem"
placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate"></textarea>
<div class="form-text mt-1">
<span class="me-1">Variables (click to insert):</span>
<span id="cfVariablePills"></span>
@@ -0,0 +1,223 @@
@model IEnumerable<PowderCoating.Application.DTOs.Company.FormulaLibraryCardDto>
@using PowderCoating.Application.DTOs.Company
@{
ViewData["Title"] = "Community Formula Library";
var search = ViewBag.Search as string;
var outputMode = ViewBag.OutputMode as string;
var industryHint = ViewBag.IndustryHint as string;
var totalCount = (int)(ViewBag.TotalCount ?? 0);
}
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-collection me-2 text-primary"></i>Community Formula Library
</h1>
<p class="text-muted mb-0">Browse and import pricing formulas shared by the Powder Coating Logix community.</p>
</div>
<a asp-controller="CompanySettings" asp-action="Index" asp-fragment="custom-formulas"
class="btn btn-outline-secondary">
<i class="bi bi-gear me-1"></i>My Formulas
</a>
</div>
@* Search + Filter Bar *@
<div class="card mb-4 border-0 shadow-sm">
<div class="card-body py-3">
<form method="get" asp-action="Index" class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small fw-semibold mb-1">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="search" value="@search"
class="form-control" placeholder="Name, description, tags, company&hellip;" />
</div>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Output Mode</label>
<select name="outputMode" class="form-select">
<option value="">All modes</option>
<option value="FixedRate" selected="@(outputMode == "FixedRate")">Fixed Rate</option>
<option value="SurfaceAreaSqFt" selected="@(outputMode == "SurfaceAreaSqFt")">Surface Area (sq ft)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold mb-1">Industry</label>
<input type="text" name="industryHint" value="@industryHint"
class="form-control" placeholder="HVAC, Automotive&hellip;" />
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-funnel-fill"></i>
</button>
</div>
</form>
</div>
</div>
@* Results header *@
<div class="d-flex align-items-center mb-3">
<span class="text-muted small">
@totalCount formula@(totalCount == 1 ? "" : "s") in the library
@if (!string.IsNullOrWhiteSpace(search) || !string.IsNullOrWhiteSpace(outputMode) || !string.IsNullOrWhiteSpace(industryHint))
{
<span>&mdash; <a asp-action="Index" class="text-decoration-none">clear filters</a></span>
}
</span>
</div>
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-collection display-4 text-muted mb-3 d-block"></i>
<h5 class="text-muted">No formulas found</h5>
@if (!string.IsNullOrWhiteSpace(search) || !string.IsNullOrWhiteSpace(outputMode))
{
<p class="text-muted mb-0">Try broadening your search or <a asp-action="Index">view all formulas</a>.</p>
}
else
{
<p class="text-muted mb-0">Be the first to share a formula from <a asp-controller="CompanySettings" asp-action="Index" asp-fragment="custom-formulas">your templates</a>!</p>
}
</div>
}
else
{
<div class="row g-3" id="libraryGrid">
@foreach (var item in Model)
{
<div class="col-md-6 col-xl-4">
<div class="card h-100 border-0 shadow-sm formula-card @(item.IsOwnFormula ? "border-start border-warning border-3" : item.AlreadyImported ? "border-start border-success border-3" : "")">
<div class="card-body d-flex flex-column">
@* Header row *@
<div class="d-flex align-items-start gap-2 mb-2">
<div class="flex-grow-1 min-w-0">
<h6 class="fw-semibold mb-0 text-truncate" title="@item.Name">@item.Name</h6>
<small class="text-muted">
<i class="bi bi-building me-1"></i>@item.SourceCompanyName
</small>
</div>
<div class="d-flex flex-column align-items-end gap-1 flex-shrink-0">
@if (item.OutputMode == "FixedRate")
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Fixed Rate</span>
}
else
{
<span class="badge bg-info-subtle text-info border border-info-subtle">Surface Area</span>
}
@if (item.IsOwnFormula)
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">
<i class="bi bi-star-fill me-1"></i>Your Formula
</span>
}
else if (item.AlreadyImported)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">
<i class="bi bi-check-lg me-1"></i>Imported
</span>
}
</div>
</div>
@* Description *@
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="text-muted small mb-2 flex-grow-1" style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">
@item.Description
</p>
}
else
{
<div class="flex-grow-1"></div>
}
@* Inspired by *@
@if (!string.IsNullOrWhiteSpace(item.InspiredByName))
{
<p class="text-muted small mb-2 fst-italic">
<i class="bi bi-diagram-2 me-1"></i>Inspired by
&ldquo;@item.InspiredByName&rdquo; from @item.InspiredByCompanyName
</p>
}
@* Tags *@
@if (!string.IsNullOrWhiteSpace(item.Tags))
{
<div class="mb-2">
@foreach (var tag in item.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle me-1">@tag.Trim()</span>
}
</div>
}
@* Footer row *@
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<small class="text-muted">
<i class="bi bi-download me-1"></i>@item.ImportCount import@(item.ImportCount == 1 ? "" : "s")
</small>
@if (item.IsOwnFormula)
{
<a asp-controller="CompanySettings" asp-action="Index" asp-fragment="custom-formulas"
class="btn btn-sm btn-outline-warning">
<i class="bi bi-gear me-1"></i><span>Manage</span>
</a>
}
else
{
<button type="button"
class="btn btn-sm @(item.AlreadyImported ? "btn-outline-success" : "btn-outline-primary") btn-import"
data-item-id="@item.Id"
data-item-name="@item.Name">
@if (item.AlreadyImported)
{
<i class="bi bi-check-lg me-1"></i><span>Already Imported</span>
}
else
{
<i class="bi bi-cloud-download me-1"></i><span>Preview &amp; Import</span>
}
</button>
}
</div>
</div>
</div>
</div>
}
</div>
}
</div>
@* Import Preview Modal *@
<div class="modal fade" id="importModal" tabindex="-1" aria-labelledby="importModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="importModalLabel">
<i class="bi bi-cloud-download me-2"></i>Import Formula
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="importModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-muted">Loading formula details&hellip;</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btnConfirmImport" disabled>
<i class="bi bi-cloud-download me-1"></i>Import to My Formulas
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/formula-library.js" asp-append-version="true"></script>
}
@@ -141,6 +141,59 @@
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5"><i class="bi bi-collection text-primary me-2"></i>Community Formula Library</h2>
<p>
The Community Formula Library lets companies share their custom templates with the entire
Powder Coating Logix user base. Any company can browse published formulas, preview the
fields and expression, and import a copy into their own library in one click.
</p>
<p>
Access the library from <strong>Company Settings &rarr; Custom Formulas &rarr; Community Library</strong>.
</p>
<h3 class="h6 mt-3">Sharing a formula</h3>
<ol>
<li>Open <strong>Company Settings &rarr; Custom Formulas</strong>.</li>
<li>Find the template you want to share. The <strong>Library</strong> column shows its current status.</li>
<li>Click <strong>Share</strong>. Optionally add comma-separated <strong>Tags</strong> and an <strong>Industry Hint</strong> to help others discover it.</li>
<li>Click <strong>Publish to Library</strong>. The template is immediately visible to all users.</li>
</ol>
<p class="text-muted small">
Only templates you created fresh, or imported templates you have since modified, are eligible to share.
Unmodified copies of someone else&rsquo;s formula cannot be re-published (this keeps the library from filling with duplicates).
</p>
<h3 class="h6 mt-3">Browsing and importing</h3>
<ol>
<li>Open the library via the <strong>Community Library</strong> button on the Custom Formulas tab.</li>
<li>Use the search bar, <strong>Output Mode</strong> filter, or <strong>Industry</strong> field to narrow results.</li>
<li>Click <strong>Preview &amp; Import</strong> on any card to see the full formula, fields, and diagram before committing.</li>
<li>Click <strong>Import to My Formulas</strong> in the preview modal. A private copy is added to your local template library.</li>
</ol>
<p class="text-muted small">
Imported copies are fully independent &mdash; you can edit, rename, or delete them without affecting the original.
If the original creator removes their diagram image, the image is also cleared from your copy automatically.
</p>
<h3 class="h6 mt-3">Attribution &amp; &ldquo;Inspired by&rdquo;</h3>
<p>
Every library card shows the <strong>source company name</strong> so credit stays with the original creator.
If you import a formula, modify it, and then share your version back to the community, your card will display
an <em>&ldquo;Inspired by &hellip;&rdquo;</em> line crediting the formula it was derived from.
</p>
<h3 class="h6 mt-3">Your own shared formulas</h3>
<p>
Formulas your company has published appear in the library with a gold <strong>Your Formula</strong> badge.
Clicking <strong>Manage</strong> on your own card takes you back to Company Settings where you can edit or unshare it.
To remove a formula from the community library, click <strong>Unshare</strong> in the Library column &mdash;
it disappears from the browse page immediately, but anyone who already imported it keeps their copy.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">NCalc formula reference</h2>
@@ -81,6 +81,10 @@
asp-controller="Help" asp-action="Settings">
<i class="bi bi-gear"></i> Settings
</a>
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "CustomFormulaTemplates" ? "active fw-semibold text-primary" : "text-body")"
asp-controller="Help" asp-action="CustomFormulaTemplates">
<i class="bi bi-calculator"></i> Custom Formulas
</a>
<div class="px-3 pt-2 pb-1">
<span class="text-muted text-uppercase" style="font-size:.65rem; letter-spacing:.07em; font-weight:600;">Account</span>
@@ -1504,7 +1504,7 @@ var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports")
{
<li><a class="dropdown-item" asp-controller="CompanyUsers" asp-action="Index"><i class="bi bi-people-fill me-2"></i>Manage Users</a></li>
<li><a class="dropdown-item" asp-controller="PricingTiers" asp-action="Index"><i class="bi bi-tags me-2"></i>Pricing Tiers</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
<li><a class="dropdown-item" asp-controller="TaxRates" asp-action="Index"><i class="bi bi-percent me-2"></i>Tax Rates</a></li>
<li><a class="dropdown-item" asp-controller="Kiosk" asp-action="Activate"><i class="bi bi-tablet me-2"></i>Kiosk Setup</a></li>
}
<li><hr class="dropdown-divider"></li>
@@ -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());
}
})();