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
@@ -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>