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,69 @@
namespace PowderCoating.Application.DTOs.Company;
// ── Browse / card display ──────────────────────────────────────────────────
/// <summary>Lean DTO for the community library browse grid card.</summary>
public class FormulaLibraryCardDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
public string SourceCompanyName { get; set; } = string.Empty;
public int ImportCount { get; set; }
public DateTime SharedAt { get; set; }
public string? DiagramImagePath { get; set; }
/// <summary>Non-null when this formula was derived from another library entry.</summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public string? InspiredByName { get; set; }
public string? InspiredByCompanyName { get; set; }
/// <summary>True when the current company has already imported this entry.</summary>
public bool AlreadyImported { get; set; }
/// <summary>True when this formula was shared by the current browsing company.</summary>
public bool IsOwnFormula { get; set; }
}
// ── Full detail (import preview modal) ────────────────────────────────────
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
{
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int FieldCount { get; set; }
}
// ── Share from Company Settings ───────────────────────────────────────────
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
public class ShareFormulaRequest
{
public int CustomItemTemplateId { get; set; }
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
}
// ── Company Settings list view ─────────────────────────────────────────────
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
public class FormulaLibraryStatusDto
{
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
public int? LibraryItemId { get; set; }
public bool IsPublished { get; set; }
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
public bool CanShare { get; set; }
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
public string? ImportedFromName { get; set; }
public string? ImportedFromCompany { get; set; }
}
@@ -0,0 +1,51 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
/// </summary>
public interface IFormulaLibraryService
{
/// <summary>
/// Returns all published library entries, with AlreadyImported populated for the given company.
/// Optionally filters by search term, output mode, or industry hint.
/// </summary>
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId,
string? search = null,
string? outputMode = null,
string? industryHint = null);
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
/// <summary>
/// Publishes a company template to the community library.
/// If the template was previously shared and unpublished, re-publishes the existing row.
/// Updates the library entry fields from the current template state on re-share.
/// </summary>
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
Task UnshareAsync(int libraryItemId, int companyId);
/// <summary>
/// Copies a library entry into the company's local CustomItemTemplate table.
/// If the company already has an import record for this entry, returns the existing template id.
/// </summary>
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
/// <summary>
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
/// eligible to be shared, and where it was imported from if applicable.
/// </summary>
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
/// <summary>
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
/// when a source template's diagram is removed. Call from CompanySettingsController
/// when a diagram is deleted or replaced.
/// </summary>
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
}
@@ -0,0 +1,35 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class FormulaLibraryProfile : Profile
{
public FormulaLibraryProfile()
{
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.InspiredByName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
.ForMember(dest => dest.InspiredByCompanyName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
}
private static int CountFields(string fieldsJson)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
? doc.RootElement.GetArrayLength()
: 0;
}
catch { return 0; }
}
}
@@ -35,4 +35,19 @@ public class CustomItemTemplate : BaseEntity
/// Path format: {companyId}/{templateId}/diagram.{ext}
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Community library tracking ─────────────────────────────────────────
/// <summary>
/// Set when this template was imported from the community library.
/// Null for originally created templates.
/// </summary>
public int? SourceFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
/// <summary>
/// True once the user edits an imported template. Only modified imports (and original
/// creations) are eligible to be shared back to the community library.
/// </summary>
public bool IsModifiedFromSource { get; set; }
}
@@ -0,0 +1,19 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
/// same item overwrites the existing row rather than creating a duplicate.
/// </summary>
public class FormulaLibraryImport : BaseEntity
{
public int FormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
public string ImportedByUserId { get; set; } = string.Empty;
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
public int ResultingCustomItemTemplateId { get; set; }
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
}
@@ -0,0 +1,70 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-level community library entry for a shared custom formula template.
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
/// Shared voluntarily by the originating company; imported as independent copies by others.
/// </summary>
public class FormulaLibraryItem
{
public int Id { get; set; }
// ── Formula content (copied from CustomItemTemplate at share time) ─────
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
public string FieldsJson { get; set; } = "[]";
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
/// <summary>
/// Blob path referencing the source template's diagram image.
/// Nulled out (here and on all imports) if the source template's diagram is removed.
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Attribution ────────────────────────────────────────────────────────
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
public string? Tags { get; set; }
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
public string? IndustryHint { get; set; }
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
public int SourceCustomItemTemplateId { get; set; }
public int SourceCompanyId { get; set; }
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
public string SourceCompanyName { get; set; } = string.Empty;
/// <summary>
/// When non-null, this entry was derived from an imported formula that was subsequently
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
/// </summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? InspiredBy { get; set; }
public string SharedByUserId { get; set; } = string.Empty;
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
/// <summary>False when the creator has removed it from the community library.</summary>
public bool IsPublished { get; set; } = true;
/// <summary>Running count of how many companies have imported this entry.</summary>
public int ImportCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
@@ -158,6 +158,10 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
// Formula Community Library
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
// Employee Timeclock
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
@@ -289,6 +289,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// </summary>
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -2074,6 +2080,49 @@ modelBuilder.Entity<Job>()
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
.IsUnique()
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
modelBuilder.Entity<FormulaLibraryItem>()
.HasOne(f => f.InspiredBy)
.WithMany()
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.SourceCompanyId)
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.IsPublished)
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.FormulaLibraryItem)
.WithMany()
.HasForeignKey(i => i.FormulaLibraryItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.ResultingCustomItemTemplate)
.WithMany()
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
modelBuilder.Entity<CustomItemTemplate>()
.HasOne(t => t.SourceFormulaLibraryItem)
.WithMany()
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
}
/// <summary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFormulaLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsModifiedFromSource",
table: "CustomItemTemplates",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "FormulaLibraryItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsPublished = table.Column<bool>(type: "bit", nullable: false),
ImportCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
column: x => x.InspiredByFormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "FormulaLibraryImports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
column: x => x.ResultingCustomItemTemplateId,
principalTable: "CustomItemTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
column: x => x.FormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
migrationBuilder.CreateIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_Company_Item",
table: "FormulaLibraryImports",
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
table: "FormulaLibraryImports",
column: "FormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
table: "FormulaLibraryImports",
column: "ResultingCustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
table: "FormulaLibraryItems",
column: "InspiredByFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_IsPublished",
table: "FormulaLibraryItems",
column: "IsPublished");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_SourceCompanyId",
table: "FormulaLibraryItems",
column: "SourceCompanyId");
migrationBuilder.AddForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId",
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropTable(
name: "FormulaLibraryImports");
migrationBuilder.DropTable(
name: "FormulaLibraryItems");
migrationBuilder.DropIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "IsModifiedFromSource",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
}
}
@@ -2711,6 +2711,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsModifiedFromSource")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2725,6 +2728,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SourceFormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@@ -2733,6 +2739,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("SourceFormulaLibraryItemId");
b.ToTable("CustomItemTemplates");
});
@@ -3437,6 +3445,154 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("FixedAssetDepreciationEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("FormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime>("ImportedAt")
.HasColumnType("datetime2");
b.Property<string>("ImportedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ResultingCustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("FormulaLibraryItemId");
b.HasIndex("ResultingCustomItemTemplateId");
b.HasIndex("CompanyId", "FormulaLibraryItemId")
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
b.ToTable("FormulaLibraryImports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("ImportCount")
.HasColumnType("int");
b.Property<string>("IndustryHint")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InspiredByFormulaLibraryItemId")
.HasColumnType("int");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OutputMode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("SharedAt")
.HasColumnType("datetime2");
b.Property<string>("SharedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCompanyId")
.HasColumnType("int");
b.Property<string>("SourceCompanyName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCustomItemTemplateId")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("InspiredByFormulaLibraryItemId");
b.HasIndex("IsPublished")
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
b.HasIndex("SourceCompanyId")
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
b.ToTable("FormulaLibraryItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.Property<int>("Id")
@@ -6868,7 +7024,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956),
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6879,7 +7035,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962),
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6890,7 +7046,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964),
CreatedAt = new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -9263,6 +9419,16 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Invoice");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
.WithMany()
.HasForeignKey("SourceFormulaLibraryItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SourceFormulaLibraryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9419,6 +9585,35 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
.WithMany()
.HasForeignKey("FormulaLibraryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
.WithMany()
.HasForeignKey("ResultingCustomItemTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FormulaLibraryItem");
b.Navigation("ResultingCustomItemTemplate");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
.WithMany()
.HasForeignKey("InspiredByFormulaLibraryItemId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("InspiredBy");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -130,6 +130,10 @@ public class UnitOfWork : IUnitOfWork
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
// Formula Community Library
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
// Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -476,6 +480,14 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
// Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates =>
@@ -0,0 +1,308 @@
using AutoMapper;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Manages the community formula library: sharing a company template to the platform-wide
/// library, removing it, browsing published entries, and importing an entry as a local copy.
/// </summary>
public class FormulaLibraryService : IFormulaLibraryService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<FormulaLibraryService> _logger;
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <inheritdoc />
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId, string? search, string? outputMode, string? industryHint)
{
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
if (!string.IsNullOrWhiteSpace(search))
{
var lower = search.ToLowerInvariant();
items = items.Where(i =>
i.Name.ToLowerInvariant().Contains(lower) ||
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
}
if (!string.IsNullOrWhiteSpace(outputMode))
items = items.Where(i => i.OutputMode == outputMode);
if (!string.IsNullOrWhiteSpace(industryHint))
items = items.Where(i => i.IndustryHint != null &&
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
// Load InspiredBy for attribution line
var itemList = items.ToList();
var inspiredByIds = itemList
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
.Distinct()
.ToHashSet();
Dictionary<int, FormulaLibraryItem> inspirations = new();
foreach (var id in inspiredByIds)
{
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
if (parent != null) inspirations[id] = parent;
}
// Attach navigation properties manually (PlainRepository doesn't eager-load)
foreach (var item in itemList)
{
if (item.InspiredByFormulaLibraryItemId.HasValue &&
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
item.InspiredBy = parent;
}
// Determine which entries this company has already imported
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
imp => imp.CompanyId == companyId && !imp.IsDeleted);
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
for (int i = 0; i < dtos.Count; i++)
{
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
}
return dtos.OrderByDescending(d => d.ImportCount).ThenBy(d => d.Name);
}
/// <inheritdoc />
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished) return null;
if (item.InspiredByFormulaLibraryItemId.HasValue)
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
item.InspiredByFormulaLibraryItemId.Value);
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
dto.AlreadyImported = imp.Any();
return dto;
}
/// <inheritdoc />
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
if (template == null || template.CompanyId != companyId)
throw new InvalidOperationException("Template not found.");
if (!CanShare(template))
throw new InvalidOperationException("This template is not eligible to be shared.");
// Determine "Inspired by" — if this was imported from the library
int? inspiredById = null;
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
inspiredById = template.SourceFormulaLibraryItemId;
// Get company name for attribution
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyName = company?.CompanyName ?? "Unknown Company";
// Re-use existing row if one exists (re-share after unshare, or update after edits)
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
if (existing != null)
{
CopyFromTemplate(existing, template, companyName, request);
existing.InspiredByFormulaLibraryItemId = inspiredById;
existing.IsPublished = true;
existing.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
await _unitOfWork.CompleteAsync();
return existing.Id;
}
var libraryItem = new FormulaLibraryItem
{
SharedByUserId = userId,
SharedAt = DateTime.UtcNow,
SourceCustomItemTemplateId = template.Id,
SourceCompanyId = companyId,
SourceCompanyName = companyName,
InspiredByFormulaLibraryItemId = inspiredById,
IsPublished = true,
};
CopyFromTemplate(libraryItem, template, companyName, request);
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return libraryItem.Id;
}
/// <inheritdoc />
public async Task UnshareAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || item.SourceCompanyId != companyId) return;
item.IsPublished = false;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
await _unitOfWork.CompleteAsync();
}
/// <inheritdoc />
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
{
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (libraryItem == null || !libraryItem.IsPublished)
throw new InvalidOperationException("Library entry not found or no longer published.");
// Return existing import if already imported
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
var existingImport = existingImports.FirstOrDefault();
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
// Create a local copy as a new CustomItemTemplate
var template = new CustomItemTemplate
{
CompanyId = companyId,
Name = libraryItem.Name,
Description = libraryItem.Description,
OutputMode = libraryItem.OutputMode,
FieldsJson = libraryItem.FieldsJson,
Formula = libraryItem.Formula,
DefaultRate = libraryItem.DefaultRate,
RateLabel = libraryItem.RateLabel,
Notes = libraryItem.Notes,
DiagramImagePath = libraryItem.DiagramImagePath,
DisplayOrder = 0,
IsActive = true,
SourceFormulaLibraryItemId = libraryItemId,
IsModifiedFromSource = false,
};
await _unitOfWork.CustomItemTemplates.AddAsync(template);
await _unitOfWork.CompleteAsync();
var importRecord = new FormulaLibraryImport
{
CompanyId = companyId,
FormulaLibraryItemId = libraryItemId,
ImportedByUserId = userId,
ImportedAt = DateTime.UtcNow,
ResultingCustomItemTemplateId = template.Id,
};
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
// Increment import counter
libraryItem.ImportCount++;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return template.Id;
}
/// <inheritdoc />
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (template == null || template.CompanyId != companyId)
return new FormulaLibraryStatusDto { CanShare = false };
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
// Populate import attribution
if (template.SourceFormulaLibraryItemId.HasValue)
{
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
if (source != null)
{
dto.ImportedFromName = source.Name;
dto.ImportedFromCompany = source.SourceCompanyName;
}
}
// Check if this template has an active library entry
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
if (libraryItem != null)
{
dto.LibraryItemId = libraryItem.Id;
dto.IsPublished = libraryItem.IsPublished;
}
return dto;
}
/// <inheritdoc />
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
{
// Null out on the library item published from this template
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
if (libraryItem != null && libraryItem.DiagramImagePath != null)
{
libraryItem.DiagramImagePath = null;
libraryItem.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
// Null out on all imported copies
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
foreach (var imp in imports)
{
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
imp.ResultingCustomItemTemplateId);
if (copy != null && copy.DiagramImagePath != null)
{
copy.DiagramImagePath = null;
await _unitOfWork.CompleteAsync();
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// A template is shareable if it was created fresh (no source library item) or
/// if it was imported but then modified by the company.
/// </summary>
private static bool CanShare(CustomItemTemplate t) =>
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
private static void CopyFromTemplate(
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
{
dest.Name = src.Name;
dest.Description = src.Description;
dest.OutputMode = src.OutputMode;
dest.FieldsJson = src.FieldsJson;
dest.Formula = src.Formula;
dest.DefaultRate = src.DefaultRate;
dest.RateLabel = src.RateLabel;
dest.Notes = src.Notes;
dest.DiagramImagePath = src.DiagramImagePath;
dest.SourceCompanyName = companyName;
dest.Tags = req.Tags?.Trim();
dest.IndustryHint = req.IndustryHint?.Trim();
}
}
@@ -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());
}
})();