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:
@@ -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>
|
||||
|
||||
+11119
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):**
|
||||
|
||||
@@ -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…</td></tr>
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">Loading…</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 —
|
||||
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 “Inspired by” 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…" />
|
||||
</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…" />
|
||||
</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>— <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
|
||||
“@item.InspiredByName” 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 & 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…</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 → Custom Formulas → Community Library</strong>.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 mt-3">Sharing a formula</h3>
|
||||
<ol>
|
||||
<li>Open <strong>Company Settings → 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’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 & 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 — 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 & “Inspired by”</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>“Inspired by …”</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 —
|
||||
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…</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">Loading…</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">…</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…';
|
||||
|
||||
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
|
||||
“${escHtml(d.inspiredByName)}” 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…';
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user