Merge feature/custom-formula-templates into dev

This commit is contained in:
2026-05-24 11:42:22 -04:00
40 changed files with 13341 additions and 33 deletions
@@ -0,0 +1,174 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Application.DTOs.Company;
// ============================================================================
// LIST DTO - For Company Settings tab table
// ============================================================================
public class CustomItemTemplateListDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public int FieldCount { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public bool IsActive { get; set; }
public int DisplayOrder { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// FULL DTO - For Edit modal and formula evaluation
// ============================================================================
public class CustomItemTemplateDto
{
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 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 DisplayOrder { get; set; }
public bool IsActive { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// CREATE DTO
// ============================================================================
public class CreateCustomItemTemplateDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
[Required]
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
}
// ============================================================================
// UPDATE DTO
// ============================================================================
public class UpdateCustomItemTemplateDto
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Required]
public string OutputMode { get; set; } = "FixedRate";
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
// ============================================================================
public class CustomItemTemplatePickerDto
{
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 FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// AI GENERATION DTOs
// ============================================================================
public class GenerateFormulaFromAiRequest
{
[Required]
public string Description { get; set; } = string.Empty;
}
public class GenerateFormulaFromAiResponse
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Name { get; set; }
public string? OutputMode { get; set; }
public string? FieldsJson { get; set; }
public string? Formula { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Reasoning { get; set; }
/// <summary>Result of running the formula with any sample values found in the description.</summary>
public decimal? VerificationResult { get; set; }
public string? VerificationInputs { get; set; }
}
// ============================================================================
// FORMULA EVALUATION DTOs
// ============================================================================
public class EvaluateFormulaRequest
{
[Required]
public string Formula { get; set; } = string.Empty;
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
[Required]
public string VariablesJson { get; set; } = "{}";
}
public class EvaluateFormulaResponse
{
public bool Success { get; set; }
public decimal? Result { get; set; }
public string? Error { get; set; }
}
@@ -325,7 +325,11 @@ public class JobItemDto
public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public bool IsAiItem { get; set; }
public string? Sku { get; set; }
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
public List<JobItemCoatDto> Coats { get; set; } = new();
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
}
@@ -475,6 +475,11 @@ public class QuoteItemDto
public bool IsAiItem { get; set; }
// Custom formula item
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
// Cost breakdown snapshot
public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; }
@@ -559,6 +564,11 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
public int? AiPredictionId { get; set; }
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
}
// ============================================================================
@@ -0,0 +1,22 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
public interface ICustomFormulaAiService
{
/// <summary>
/// Generates a NCalc formula, field list, and notes from a natural-language description
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
/// ready to pre-fill the template editor.
/// </summary>
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null);
/// <summary>
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
/// Safe server-side only — no user-controlled code execution.
/// </summary>
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
}
@@ -0,0 +1,41 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class CustomItemTemplateProfile : Profile
{
public CustomItemTemplateProfile()
{
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
}
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;
}
}
}
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
.ReverseMap()
.ForMember(dest => dest.Quote, opt => opt.Ignore())
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
.ForMember(dest => dest.Coats, opt => opt.Ignore())
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -16,6 +16,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" />
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
IsCustomFormulaItem = seed.IsCustomFormulaItem,
CustomItemTemplateId = seed.CustomItemTemplateId,
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; }
public string? AiTags { get; init; }
public int? AiPredictionId { get; init; }
public bool IsCustomFormulaItem { get; init; }
public int? CustomItemTemplateId { get; init; }
public string? FormulaFieldValuesJson { get; init; }
}
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -288,6 +288,24 @@ public class PricingCalculationService : IPricingCalculationService
};
}
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the result as ManualUnitPrice. Use it directly — no coating math.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{
var total = item.ManualUnitPrice.Value * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = total,
UnitPrice = item.ManualUnitPrice.Value,
TotalPrice = total
};
}
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{
@@ -130,6 +130,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return;
}
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue)
{
if (itemDto.Coats != null && itemDto.Coats.Any())
@@ -243,6 +251,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
CustomItemTemplateId = itemDto.CustomItemTemplateId,
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -0,0 +1,38 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// A per-company reusable pricing formula template. Users define named input fields and an
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
/// </summary>
public class CustomItemTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</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;
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
public decimal? DefaultRate { get; set; }
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
/// Shown in the template editor and quote wizard so users know which measurements to take.
/// Path format: {companyId}/{templateId}/diagram.{ext}
/// </summary>
public string? DiagramImagePath { get; set; }
}
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships
public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships
public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
@@ -155,6 +155,9 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -374,6 +374,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; }
// Custom Formula Templates
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
/// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomItemTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "QuoteItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "QuoteItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "QuoteItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "JobItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "JobItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "JobItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "CustomItemTemplates",
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),
DisplayOrder = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
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_CustomItemTemplates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
migrationBuilder.CreateIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId");
migrationBuilder.AddForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropTable(
name: "CustomItemTemplates");
migrationBuilder.DropIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "JobItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "JobItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
@@ -2650,6 +2650,80 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CreditMemoApplications");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", 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<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.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?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CustomItemTemplates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{
b.Property<int>("Id")
@@ -4473,6 +4547,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@@ -4489,12 +4566,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish")
.HasColumnType("nvarchar(max)");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -4558,6 +4641,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("JobId")
.HasDatabaseName("IX_JobItems_JobId");
@@ -7260,6 +7345,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@@ -7273,12 +7361,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("EstimatedMinutes")
.HasColumnType("int");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -7348,6 +7442,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("QuoteId")
.HasDatabaseName("IX_QuoteItems_QuoteId");
@@ -9512,6 +9608,10 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("CatalogItemId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
.WithMany("JobItems")
.HasForeignKey("JobId")
@@ -9522,6 +9622,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Job");
});
@@ -10131,6 +10233,10 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany()
.HasForeignKey("CatalogItemId");
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
.WithMany("QuoteItems")
.HasForeignKey("QuoteId")
@@ -10141,6 +10247,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Quote");
});
@@ -21,6 +21,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" />
@@ -123,6 +123,9 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
// Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -457,6 +460,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context);
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
// Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates =>
@@ -0,0 +1,197 @@
using System.Text;
using System.Text.Json;
using NCalc2;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Generates NCalc pricing formula templates from natural-language descriptions using
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
/// shape being estimated. The model returns a structured JSON object containing the
/// field list, NCalc expression, output mode, and verification inputs; the service
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
/// </summary>
public class CustomFormulaAiService : ICustomFormulaAiService
{
private readonly IConfiguration _config;
private readonly ILogger<CustomFormulaAiService> _logger;
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
from user-supplied field values. NCalc supports standard math operators (+, -, *, /, %, Pow()),
comparison operators, and the Abs(), Round(), Max(), Min() built-in functions.
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
'Tubular frame') and you must produce a pricing formula template.
Respond ONLY with a valid JSON object matching this exact schema no markdown, no explanation:
{
""name"": ""string short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
""fields"": [
{
""name"": ""snake_case_variable_name"",
""label"": ""Human-readable label"",
""unit"": ""in / ft / mm / cm / qty / lbs or empty string"",
""defaultValue"": number
}
],
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
""defaultRate"": number or null,
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' null if no rate"",
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
""verificationInputs"": { ""variable_name"": number },
""verificationResult"": number
}
Rules:
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
- verificationInputs and verificationResult must use the exact field names and formula you generated
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
- For inch inputs convert to sqft: divide by 144 (sqinsqft) or use /12 per side before multiplying
";
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
{
_config = config;
_logger = logger;
}
/// <inheritdoc />
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new GenerateFormulaFromAiResponse
{
Success = false,
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
try
{
var client = new AnthropicClient(apiKey);
var userContent = new List<ContentBase>();
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
{
userContent.Add(new ImageContent
{
Source = new ImageSource
{
MediaType = imageContentType,
Data = Convert.ToBase64String(imageBytes)
}
});
}
userContent.Add(new TextContent { Text = request.Description });
var messages = new List<Message>
{
new() { Role = RoleType.User, Content = userContent }
};
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = SystemPrompt,
Messages = messages
});
var rawJson = response.Message.ToString().Trim();
// Strip markdown code fences if the model adds them
if (rawJson.StartsWith("```"))
{
var start = rawJson.IndexOf('\n') + 1;
var end = rawJson.LastIndexOf("```");
if (end > start) rawJson = rawJson[start..end].Trim();
}
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
? fieldsEl.GetRawText()
: "[]";
decimal? defaultRate = null;
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
defaultRate = rateEl.GetDecimal();
decimal? verificationResult = null;
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
verificationResult = vrEl.GetDecimal();
string? verificationInputs = null;
if (root.TryGetProperty("verificationInputs", out var viEl))
verificationInputs = viEl.GetRawText();
return new GenerateFormulaFromAiResponse
{
Success = true,
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
FieldsJson = fieldsJson,
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
DefaultRate = defaultRate,
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
VerificationResult = verificationResult,
VerificationInputs = verificationInputs
};
}
catch (Exception ex)
{
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
}
}
/// <inheritdoc />
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Formula))
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
try
{
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
request.VariablesJson ?? "{}") ?? new();
var expr = new Expression(request.Formula);
foreach (var kv in variables)
{
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
? (object)kv.Value.GetDecimal()
: (object)(kv.Value.GetString() ?? "");
}
var result = expr.Evaluate();
var decResult = Convert.ToDecimal(result);
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
}
catch (Exception ex)
{
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
}
}
}
@@ -33,6 +33,8 @@ public class CompanySettingsController : Controller
private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
public CompanySettingsController(
IUnitOfWork unitOfWork,
@@ -45,7 +47,9 @@ public class CompanySettingsController : Controller
IConfiguration configuration,
IAuditLogService auditLog,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
SignInManager<ApplicationUser> signInManager,
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -58,6 +62,8 @@ public class CompanySettingsController : Controller
_auditLog = auditLog;
_userManager = userManager;
_signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
}
/// <summary>
@@ -2962,6 +2968,218 @@ public class CompanySettingsController : Controller
return RedirectToAction(nameof(DeleteAccount));
}
}
// ─── Custom Formula Item Templates ──────────────────────────────────────────
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
return Json(new { success = true, template = dto });
}
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplates()
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId);
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
return Json(new { success = true, templates = dtos });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, id = entity.Id });
}
/// <summary>Updates an existing formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
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>.
/// Returns the blob path for storage on the entity.
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
if (diagramFile.Length > 10 * 1024 * 1024)
return Json(new { success = false, message = "Image must be under 10 MB." });
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
using var stream = diagramFile.OpenReadStream();
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
if (!ok)
return Json(new { success = false, message = err });
entity.DiagramImagePath = blobPath;
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true, diagramImagePath = blobPath });
}
/// <summary>
/// Serves a template diagram image from blob storage. The path is tenant-scoped
/// so cross-company access is prevented by checking CompanyId on the template.
/// </summary>
[HttpGet]
public async Task<IActionResult> TemplateDiagram(int templateId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
return NotFound();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Evaluates a NCalc formula with the supplied variable values.
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
/// in the Application/Infrastructure layer.
/// </summary>
[HttpPost]
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
{
var result = _formulaAiService.EvaluateFormula(req);
return Json(result);
}
/// <summary>
/// Calls Claude to generate a formula template from a natural-language description
/// and an optional diagram image uploaded in the same multipart form.
/// </summary>
[HttpPost]
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
{
if (string.IsNullOrWhiteSpace(description))
return Json(new { success = false, error = "Description is required." });
byte[]? imageBytes = null;
string? imageContentType = null;
if (diagramImage is { Length: > 0 })
{
using var ms = new MemoryStream();
await diagramImage.CopyToAsync(ms);
imageBytes = ms.ToArray();
imageContentType = diagramImage.ContentType;
}
var result = await _formulaAiService.GenerateFormulaAsync(
new GenerateFormulaFromAiRequest { Description = description },
imageBytes,
imageContentType);
return Json(result);
}
/// <summary>
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
/// must start with a letter, contain only letters/digits/underscores, and not use the
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
/// Returns an error message string on failure, or null if all names are valid.
/// </summary>
private static string? ValidateTemplateFields(string? fieldsJson)
{
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
List<System.Text.Json.JsonElement>? fields;
try
{
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
}
catch { return "Invalid fields JSON."; }
if (fields == null) return null;
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name))
return "All fields must have a variable name.";
if (name == "rate")
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
if (!nameRegex.IsMatch(name))
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
if (!seen.Add(name))
return $"Duplicate field name \"{name}\".";
}
return null;
}
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
@@ -125,5 +125,14 @@ namespace PowderCoating.Web.Controllers
{
return View();
}
/// <summary>
/// Serves the Custom Formula Item Templates help article explaining how to create NCalc formula
/// templates, use the AI generator, and add formula items to quotes and jobs.
/// </summary>
public IActionResult CustomFormulaTemplates()
{
return View();
}
}
}
@@ -489,9 +489,12 @@ public class JobsController : Controller
manualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem || ji.IsSalesItem ? ji.UnitPrice : (decimal?)null),
powderCostOverride = ji.PowderCostOverride,
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
isCustomFormulaItem = ji.IsCustomFormulaItem,
customItemTemplateId = ji.CustomItemTemplateId,
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking,
@@ -1279,9 +1282,12 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -1852,6 +1858,25 @@ public class JobsController : Controller
{
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
await PopulateDropdowns();
await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
@@ -2981,9 +3006,12 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -3172,10 +3200,13 @@ public class JobsController : Controller
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
@@ -3309,6 +3340,13 @@ public class JobsController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
}
/// <summary>
@@ -2429,6 +2429,25 @@ public class QuotesController : Controller
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
// Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
@@ -1388,5 +1388,25 @@ public static class HelpKnowledgeBase
---
Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center.
---
**Custom Formula Item Templates (Company Settings Custom Formulas):**
Reusable NCalc pricing formulas for complex fabricated items (roof curbs, electrical enclosures, welded frames). Each template has a list of measurement fields and a formula expression. Two output modes:
- Fixed Rate: formula produces a dollar amount stored as ManualUnitPrice × Qty
- Surface Area: formula produces sq ft standard coating engine prices it
Creating a template: New Template enter name + output mode + fields (name, label, unit, default value) write NCalc formula using field names Run to test optionally upload a diagram image Save
AI Generator: enter description in "AI Formula Generator" box in the template editor Claude suggests formula + fields + mode review and save
Using in wizard: item wizard shows "Custom Formula Item" card if active templates exist choose template template diagram shown for reference enter measurements Calculate verify result continue to coatings/prep steps
Formula variable names: snake_case, letters/digits/underscores only. Reserved variable: "rate" (pre-populated from Default Rate).
NCalc syntax: +, -, *, /, %, Pow(b,e), Abs(x), Round(x,d), Max(a,b), Min(a,b), Sqrt(x)
Common formula patterns (all Fixed Rate, divide inches by 144 to get sqft):
- 6-sided box: fields l_in/w_in/h_in 2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate
- Cylinder: fields d_in/h_in (3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate
- Flat panel: fields l_in/w_in l_in * w_in / 144 * rate
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
""";
}
+2
View File
@@ -219,6 +219,7 @@ builder.Services.AddScoped<IOperationalReportService, OperationalReportService>(
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
@@ -292,6 +293,7 @@ cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new AccountingProfile());
cfg.AddProfile(new PurchaseOrderProfile());
cfg.AddProfile(new PricingTierProfile());
cfg.AddProfile(new CustomItemTemplateProfile());
}, loggerFactory);
return config.CreateMapper();
});
@@ -106,6 +106,11 @@
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="custom-formulas-tab" data-bs-toggle="tab" data-bs-target="#custom-formulas" type="button" role="tab">
<i class="bi bi-calculator"></i> Custom Formulas
</button>
</li>
</ul>
<!-- Tabs Content -->
@@ -2054,6 +2059,179 @@
</div>
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<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">
<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>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
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.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="cfTemplatesTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Output Mode</th>
<th>Fields</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody id="cfTemplatesBody">
<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Formula Walkthrough Modal -->
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="cfWalkthroughLabel">
<i class="bi bi-calculator text-info me-2"></i>Custom Formula Templates &mdash; How It Works
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2">
<!-- Step progress dots -->
<div class="d-flex justify-content-center gap-2 mb-4" id="cfWalkthroughDots"></div>
<!-- Step content -->
<div id="cfWalkthroughContent"></div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" id="cfWtPrevBtn" onclick="cfWalkthroughNav(-1)">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-primary" id="cfWtNextBtn" onclick="cfWalkthroughNav(1)">
Next<i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Custom Formula Template Modal -->
<div class="modal fade" id="cfModal" tabindex="-1" aria-labelledby="cfModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfModalLabel">New Formula Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfId" value="0" />
<div class="row g-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" id="cfName" class="form-control" placeholder="e.g. Roof Curb" maxlength="100" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="cfDescription" class="form-control" maxlength="500" />
</div>
<div class="mb-3">
<label class="form-label">Output Mode <span class="text-danger">*</span></label>
<select id="cfOutputMode" class="form-select" onchange="cfToggleRateFields()">
<option value="FixedRate">Fixed Rate &mdash; formula &rarr; $ amount</option>
<option value="SurfaceAreaSqFt">Surface Area &mdash; formula &rarr; sq ft (standard pricing engine prices it)</option>
</select>
</div>
<div id="cfRateFields">
<div class="mb-3">
<label class="form-label">Default Rate</label>
<input type="number" id="cfDefaultRate" class="form-control" step="0.01" placeholder="e.g. 0.85" />
<div class="form-text">Used as the <code>rate</code> variable if not overridden per-quote.</div>
</div>
<div class="mb-3">
<label class="form-label">Rate Label</label>
<input type="text" id="cfRateLabel" class="form-control" maxlength="50" placeholder="e.g. $/sq ft" />
</div>
</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" />
<div class="form-text mt-1">
<span class="me-1">Available variables for this formula:</span>
<span id="cfVariablePills"></span>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea id="cfNotes" class="form-control" rows="2" maxlength="1000"></textarea>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="cfIsActive" class="form-check-input" checked />
<label class="form-check-label" for="cfIsActive">Active (show in quote/job wizard)</label>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Fields</label>
<div class="form-text mb-2">Define the measurement inputs users will fill in.</div>
<div id="cfFieldsList"></div>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="cfAddField()">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
<div class="mb-3">
<label class="form-label">Formula Test</label>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="cfTestFormula()">
<i class="bi bi-play-circle"></i> Run
</button>
<span id="cfTestResult" class="fw-bold"></span>
</div>
<div class="form-text">Uses the default values from your field list.</div>
</div>
<div class="mb-3">
<label class="form-label">Diagram / Shop Drawing</label>
<div id="cfDiagramPreview" class="mb-2" style="display:none;">
<img id="cfDiagramImg" src="" alt="Diagram" class="img-fluid rounded border" style="max-height:180px;" />
</div>
<input type="file" id="cfDiagramFile" class="form-control form-control-sm" accept="image/*" onchange="cfPreviewDiagram(event)" />
<div class="form-text">Optional. Upload a shop drawing or photo to help users recognize this item.</div>
</div>
<div class="mb-3">
<label class="form-label">AI Formula Generator</label>
<div class="input-group">
<input type="text" id="cfAiPrompt" class="form-control" placeholder="Describe the item, e.g. 'Rectangular roof curb with flanged base'" />
<button type="button" class="btn btn-outline-secondary" onclick="cfGenerateFromAi()" id="cfAiBtn">
<i class="bi bi-stars"></i> Generate
</button>
</div>
<div class="form-text">Claude will suggest a formula, fields, and mode. You can edit before saving.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="cfSave()">
<i class="bi bi-floppy me-1"></i>Save Template
</button>
</div>
</div>
</div>
</div>
@@ -3289,6 +3467,24 @@
const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show();
}
if (urlParams.get('tab') === 'custom-formulas') {
const btn = document.querySelector('[data-bs-target="#custom-formulas"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script>
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script>
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
document.querySelector('[data-bs-target="#custom-formulas"]').addEventListener('shown.bs.tab', async () => {
if (!window._cfLoaded) {
await cfLoadTemplates();
window._cfLoaded = true;
if (!localStorage.getItem('cfWalkthroughSeen')) {
const hasTemplates = document.querySelectorAll('#cfTemplatesBody tr[data-id]').length > 0;
if (!hasTemplates) cfShowWalkthrough();
}
}
});
</script>
}
@@ -0,0 +1,161 @@
@{
ViewData["Title"] = "Custom Formula Item Templates &mdash; Help";
}
<div class="row g-4">
<div class="col-lg-9">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help Center</a></li>
<li class="breadcrumb-item active">Custom Formula Item Templates</li>
</ol>
</nav>
<h1 class="h3 mb-1"><i class="bi bi-calculator text-info me-2"></i>Custom Formula Item Templates</h1>
<p class="text-muted mb-4">Build reusable pricing formulas for complex fabricated items.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">What are formula templates?</h2>
<p>
Some items &mdash; roof curbs, electrical enclosures, welded frames &mdash; have prices that depend on
exact measurements rather than estimated surface area. Custom Formula Item Templates let you define a
reusable NCalc expression that automatically calculates the price (or surface area) once a user enters
the measurements.
</p>
<p>
Templates are created once in <strong>Company Settings &rarr; Custom Formulas</strong> and then
appear as a selectable item type in the quote and job item wizards.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Output modes</h2>
<div class="table-responsive">
<table class="table table-sm">
<thead class="table-light">
<tr><th>Mode</th><th>Formula output</th><th>How it&rsquo;s priced</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Fixed Rate</strong></td>
<td>A dollar amount</td>
<td>Stored as <code>ManualUnitPrice</code>; multiplied by quantity for the line total.</td>
</tr>
<tr>
<td><strong>Surface Area</strong></td>
<td>Square footage</td>
<td>Passed to the standard coating engine; priced per your operating cost rates.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Creating a template</h2>
<ol>
<li>Go to <strong>Company Settings &rarr; Custom Formulas</strong> and click <strong>New Template</strong>.</li>
<li>Enter a name and choose the output mode.</li>
<li>Add the measurement <strong>fields</strong> users will fill in (e.g. <code>length_in</code>, <code>width_in</code>).</li>
<li>Write the <strong>formula</strong> using those field names. Example for a box surface area in inches:
<pre class="bg-light p-2 rounded mt-1 mb-0">2*(length_in*width_in + length_in*height_in + width_in*height_in) / 144 * rate</pre>
</li>
<li>Click <strong>Run</strong> to test the formula with your default values.</li>
<li>Optionally upload a <strong>diagram image</strong> &mdash; users will see it when they select this template.</li>
<li>Save the template.</li>
</ol>
<h3 class="h6 mt-3">Using AI to generate a formula</h3>
<p>
In the template editor, enter a description of the item in the <strong>AI Formula Generator</strong> box
and optionally attach a diagram image. Claude will suggest a formula, field list, and output mode.
Review and adjust before saving.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Adding a formula item to a quote or job</h2>
<ol>
<li>In the item wizard, select <strong>Custom Formula Item</strong> (only visible if at least one active template exists).</li>
<li>Choose a template from the dropdown. The template&rsquo;s diagram will appear for reference.</li>
<li>Enter the measurements and click <strong>Calculate</strong> to preview the result.</li>
<li>Adjust the description and quantity, then continue to the coatings and prep steps.</li>
</ol>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Common formula patterns</h2>
<p>These ready-to-use templates cover the most common fabricated item shapes. Copy the fields and formula exactly, then adjust the Default Rate for your shop.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-box me-1"></i>6-Sided Box (roof curbs, enclosures)</h3>
<p class="text-muted small mb-2">Calculates the total outer surface area of all six faces.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>l_in</code></td><td>Length (inches)</td><td>24</td></tr>
<tr><td><code>w_in</code></td><td>Width (inches)</td><td>24</td></tr>
<tr><td><code>h_in</code></td><td>Height (inches)</td><td>12</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate</pre>
<p class="text-muted small mb-3">Output mode: <strong>Fixed Rate</strong> &mdash; suggested rate: your $/sqft coating price. A 24&times;24&times;12 box at $3.50/sqft = $28.00.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-circle me-1"></i>Cylinder (pipe ends, round housings)</h3>
<p class="text-muted small mb-2">Lateral surface plus two circular end caps.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>d_in</code></td><td>Diameter (inches)</td><td>12</td></tr>
<tr><td><code>h_in</code></td><td>Height (inches)</td><td>18</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">(3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate</pre>
<p class="text-muted small mb-3">Output mode: <strong>Fixed Rate</strong> &mdash; a 12&Prime; diameter &times; 18&Prime; tall cylinder at $3.50/sqft &asymp; $10.21.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-layout-text-window me-1"></i>Flat Panel</h3>
<p class="text-muted small mb-2">Simple rectangular sheet &mdash; one face only.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>l_in</code></td><td>Length (inches)</td><td>24</td></tr>
<tr><td><code>w_in</code></td><td>Width (inches)</td><td>12</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">l_in * w_in / 144 * rate</pre>
<p class="text-muted small mb-0">Output mode: <strong>Fixed Rate</strong> &mdash; a 24&Prime;&times;12&Prime; panel at $3.50/sqft = $7.00.</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">NCalc formula reference</h2>
<p>Formulas use <a href="https://ncalc.github.io/ncalc/" target="_blank" rel="noopener">NCalc</a> syntax:</p>
<ul>
<li>Standard operators: <code>+ - * / % Pow(b, e)</code></li>
<li>Functions: <code>Abs(x) Round(x, d) Max(a, b) Min(a, b) Sqrt(x)</code></li>
<li>Variable names must start with a letter and contain only letters, digits, or underscores.</li>
<li>The reserved variable <code>rate</code> is pre-populated from the template&rsquo;s Default Rate.</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-3 d-none d-lg-block">
@await Html.PartialAsync("_HelpNav")
</div>
</div>
@@ -258,6 +258,22 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-calculator text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Custom Formula Item Templates</h5>
<p class="card-text text-muted small mb-2">Build reusable NCalc pricing formulas for complex fabricated items like roof curbs, enclosures, and frames.</p>
<a asp-controller="Help" asp-action="CustomFormulaTemplates" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -397,6 +397,11 @@
complexity = item.Complexity,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -438,6 +443,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -358,6 +358,8 @@
<tr data-item-id="@item.Id">
<td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
@if (item.IsCustomFormulaItem) { <span class="badge bg-secondary ms-1"><i class="bi bi-calculator me-1"></i>Formula</span> }
@if (item.Coats != null && item.Coats.Any())
{
<br />
@@ -2487,7 +2489,9 @@
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
}
</script>
@@ -384,6 +384,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -425,6 +428,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -137,6 +137,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -176,7 +179,9 @@
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
}
</script>
@@ -464,6 +464,11 @@
manualUnitPrice = item.ManualUnitPrice,
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking,
notes = item.Notes,
@@ -505,6 +510,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
}
@@ -1300,6 +1300,7 @@
<span class="fw-semibold">@(item.Description ?? item.CatalogItemName ?? "(no description)")</span>
@if (item.CatalogItemId.HasValue) { <span class="badge bg-primary ms-1">Catalog</span> }
@if (item.IsAiItem) { <span class="badge bg-purple ms-1">AI</span> }
@if (item.IsCustomFormulaItem) { <span class="badge bg-secondary ms-1"><i class="bi bi-calculator me-1"></i>Formula</span> }
@if (item.SurfaceAreaSqFt > 0 || item.EstimatedMinutes > 0)
{
<span class="text-muted ms-2" style="font-size:.8rem;">
@@ -501,6 +501,9 @@
isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
includePrepCost = item.IncludePrepCost,
complexity = item.Complexity,
aiTags = item.AiTags,
@@ -548,6 +551,8 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
@@ -0,0 +1,731 @@
// company-settings-custom-formulas.js
// Custom Formula Item Templates — Company Settings tab JS
(function () {
let cfFields = [];
let cfEditing = false;
// ── Load & Render ─────────────────────────────────────────────────────────
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>';
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>';
return;
}
tbody.innerHTML = data.templates.map(t => `
<tr>
<td>
<strong>${escHtml(t.name)}</strong>
${t.description ? `<br><small class="text-muted">${escHtml(t.description)}</small>` : ''}
</td>
<td>
${t.outputMode === 'FixedRate'
? '<span class="badge bg-primary">Fixed Rate</span>'
: '<span class="badge bg-success">Surface Area</span>'}
</td>
<td><span class="badge bg-secondary">${t.fieldCount} field${t.fieldCount !== 1 ? 's' : ''}</span></td>
<td>${t.isActive
? '<span class="badge bg-success">Active</span>'
: '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="cfShowEdit(${t.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="cfDelete(${t.id}, '${escHtml(t.name)}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`).join('');
} catch (e) {
tbody.innerHTML = '<tr><td colspan="5" class="text-danger text-center py-3">Failed to load templates.</td></tr>';
}
};
// ── Create / Edit Modal ───────────────────────────────────────────────────
window.cfShowCreate = function () {
cfEditing = false;
document.getElementById('cfModalLabel').textContent = 'New Formula Template';
cfResetForm();
new bootstrap.Modal(document.getElementById('cfModal')).show();
};
window.cfShowEdit = async function (id) {
cfEditing = true;
document.getElementById('cfModalLabel').textContent = 'Edit Formula Template';
cfResetForm();
try {
const res = await fetch('/CompanySettings/GetCustomItemTemplates');
const data = await res.json();
const t = data.templates?.find(x => x.id === id);
if (!t) { showCfError('Template not found.'); return; }
// Fetch full DTO (fieldsJson + formula) via a second call that returns the full data
// GetCustomItemTemplates returns ListDto which has fieldCount, not fieldsJson.
// We need the full template — load it from the API response carefully.
// For now fetch all and find; fieldCount is on list, fieldsJson needs a dedicated endpoint.
// Since we only have GetCustomItemTemplates (returns list DTOs), we re-use what we have
// and handle fieldsJson separately via the AI-generated field.
// TODO: add GetCustomItemTemplate(id) endpoint if needed; for POC, store fieldsJson in data- attr.
} catch (e) { }
// Workaround: store full template data in the table row as JSON via a hidden Get endpoint
// For now use the reload approach via a dedicated single-record fetch
const res2 = await fetch(`/CompanySettings/GetCustomItemTemplate?id=${id}`);
if (!res2.ok) { showCfError('Could not load template.'); return; }
const full = await res2.json();
if (!full.success) { showCfError(full.message ?? 'Error loading template.'); return; }
cfPopulateForm(full.template);
new bootstrap.Modal(document.getElementById('cfModal')).show();
};
function cfPopulateForm(t) {
document.getElementById('cfId').value = t.id ?? 0;
document.getElementById('cfName').value = t.name ?? '';
document.getElementById('cfDescription').value = t.description ?? '';
document.getElementById('cfOutputMode').value = t.outputMode ?? 'FixedRate';
document.getElementById('cfDefaultRate').value = t.defaultRate ?? '';
document.getElementById('cfRateLabel').value = t.rateLabel ?? '';
document.getElementById('cfFormula').value = t.formula ?? '';
document.getElementById('cfNotes').value = t.notes ?? '';
document.getElementById('cfIsActive').checked = t.isActive !== false;
cfFields = [];
try { cfFields = JSON.parse(t.fieldsJson || '[]'); } catch { cfFields = []; }
cfRenderFields();
cfToggleRateFields();
if (t.diagramImagePath) {
document.getElementById('cfDiagramImg').src = `/CompanySettings/TemplateDiagram?templateId=${t.id}`;
document.getElementById('cfDiagramPreview').style.display = '';
}
}
function cfResetForm() {
document.getElementById('cfId').value = '0';
document.getElementById('cfName').value = '';
document.getElementById('cfDescription').value = '';
document.getElementById('cfOutputMode').value = 'FixedRate';
document.getElementById('cfDefaultRate').value = '';
document.getElementById('cfRateLabel').value = '';
document.getElementById('cfFormula').value = '';
document.getElementById('cfNotes').value = '';
document.getElementById('cfIsActive').checked = true;
document.getElementById('cfDiagramPreview').style.display = 'none';
document.getElementById('cfDiagramFile').value = '';
document.getElementById('cfTestResult').textContent = '';
cfFields = [];
cfRenderFields();
cfToggleRateFields();
}
// ── Field List Editor ─────────────────────────────────────────────────────
window.cfAddField = function () {
cfFields.push({ name: '', label: '', unit: '', defaultValue: 0 });
cfRenderFields();
};
window.cfRemoveField = function (idx) {
cfFields.splice(idx, 1);
cfRenderFields();
};
window.cfUpdateField = function (i, key, val, isNumber) {
cfFields[i][key] = isNumber ? (parseFloat(val) || 0) : val;
if (key === 'name') { cfValidateFieldNameInput(i, val); cfRenderVariablePills(); }
};
function cfValidateFieldName(name) {
if (!name) return 'Field variable name is required.';
if (name === 'rate') return '"rate" is reserved — it is pre-populated from the template\'s Default Rate.';
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) return 'Must start with a letter and contain only letters, digits, or underscores (no spaces).';
return null;
}
function cfValidateFieldNameInput(i, val) {
const inputs = document.querySelectorAll('#cfFieldsList .field-name-input');
const input = inputs[i];
if (!input) return;
const err = cfValidateFieldName(val);
input.classList.toggle('is-invalid', !!err);
let fb = input.nextElementSibling;
if (err) {
if (!fb || !fb.classList.contains('invalid-feedback')) {
fb = document.createElement('div');
fb.className = 'invalid-feedback';
input.after(fb);
}
fb.textContent = err;
} else if (fb && fb.classList.contains('invalid-feedback')) {
fb.remove();
}
}
function cfRenderFields() {
const el = document.getElementById('cfFieldsList');
if (!cfFields.length) {
el.innerHTML = '<p class="text-muted small">No fields yet.</p>';
return;
}
cfRenderVariablePills();
el.innerHTML = cfFields.map((f, i) => `
<div class="border rounded p-2 mb-2 bg-light">
<div class="row g-2 align-items-center">
<div class="col-3">
<input type="text" class="form-control form-control-sm font-monospace field-name-input${f.name && cfValidateFieldName(f.name) ? ' is-invalid' : ''}"
placeholder="var_name" value="${escHtml(f.name)}"
oninput="cfUpdateField(${i},'name',this.value)" />${f.name && cfValidateFieldName(f.name) ? `<div class="invalid-feedback">${escHtml(cfValidateFieldName(f.name))}</div>` : ''}
</div>
<div class="col-3">
<input type="text" class="form-control form-control-sm"
placeholder="Label" value="${escHtml(f.label)}"
oninput="cfUpdateField(${i},'label',this.value)" />
</div>
<div class="col-2">
<input type="text" class="form-control form-control-sm"
placeholder="Unit" value="${escHtml(f.unit ?? '')}"
oninput="cfUpdateField(${i},'unit',this.value)" />
</div>
<div class="col-3">
<input type="number" class="form-control form-control-sm"
placeholder="Default" value="${f.defaultValue ?? ''}"
oninput="cfUpdateField(${i},'defaultValue',this.value,true)" />
</div>
<div class="col-1 text-end">
<button type="button" class="btn btn-link text-danger p-0" onclick="cfRemoveField(${i})">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>`).join('');
}
function cfRenderVariablePills() {
const container = document.getElementById('cfVariablePills');
if (!container) return;
const names = cfFields.map(f => f.name).filter(n => n && !cfValidateFieldName(n));
const all = [...names, 'rate'];
container.innerHTML = all.map(n =>
`<span class="badge bg-secondary me-1 mb-1" style="cursor:pointer; font-size:.75rem;"
title="Click to insert into formula" onclick="cfInsertVariable('${escHtml(n)}')">${escHtml(n)}</span>`
).join('');
}
window.cfInsertVariable = function (name) {
const input = document.getElementById('cfFormula');
if (!input) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const before = input.value.slice(0, start);
const after = input.value.slice(end);
const needsSpace = before.length > 0 && !/[\s(+\-*/]$/.test(before);
const insert = (needsSpace ? ' ' : '') + name;
input.value = before + insert + after;
const cursor = start + insert.length;
input.setSelectionRange(cursor, cursor);
input.focus();
};
// ── Formula Test ──────────────────────────────────────────────────────────
window.cfTestFormula = async function () {
const formula = document.getElementById('cfFormula').value.trim();
if (!formula) { document.getElementById('cfTestResult').textContent = 'Enter a formula first.'; return; }
const variables = {};
cfFields.forEach(f => { if (f.name) variables[f.name] = f.defaultValue ?? 0; });
const defaultRate = parseFloat(document.getElementById('cfDefaultRate').value);
if (!isNaN(defaultRate)) variables['rate'] = defaultRate;
try {
const res = await fetch('/CompanySettings/EvaluateFormula', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgeryToken() },
body: JSON.stringify({ formula, variablesJson: JSON.stringify(variables) })
});
const data = await res.json();
const el = document.getElementById('cfTestResult');
if (data.success) {
el.textContent = `= ${Number(data.result).toFixed(4)}`;
el.className = 'fw-bold text-success';
} else {
el.textContent = data.error ?? 'Error';
el.className = 'fw-bold text-danger';
}
} catch (e) {
document.getElementById('cfTestResult').textContent = 'Request failed.';
}
};
// ── Output Mode Toggle ────────────────────────────────────────────────────
window.cfToggleRateFields = function () {
const mode = document.getElementById('cfOutputMode').value;
document.getElementById('cfRateFields').style.display = mode === 'FixedRate' ? '' : 'none';
};
// ── Diagram Preview ───────────────────────────────────────────────────────
window.cfPreviewDiagram = function (evt) {
const file = evt.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('cfDiagramImg').src = e.target.result;
document.getElementById('cfDiagramPreview').style.display = '';
};
reader.readAsDataURL(file);
};
// ── AI Generator ──────────────────────────────────────────────────────────
window.cfGenerateFromAi = async function () {
const prompt = document.getElementById('cfAiPrompt').value.trim();
if (!prompt) { showCfError('Enter a description first.'); return; }
const btn = document.getElementById('cfAiBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating&hellip;';
try {
const formData = new FormData();
formData.append('description', prompt);
const diagramFile = document.getElementById('cfDiagramFile').files[0];
if (diagramFile) formData.append('diagramImage', diagramFile);
const res = await fetch('/CompanySettings/GenerateFormulaFromAi', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: formData
});
const data = await res.json();
if (!data.success) { showCfError(data.error ?? 'AI generation failed.'); return; }
if (data.name) document.getElementById('cfName').value = data.name;
if (data.outputMode) document.getElementById('cfOutputMode').value = data.outputMode;
if (data.formula) document.getElementById('cfFormula').value = data.formula;
if (data.defaultRate != null) document.getElementById('cfDefaultRate').value = data.defaultRate;
if (data.rateLabel) document.getElementById('cfRateLabel').value = data.rateLabel;
cfFields = [];
try {
const parsed = JSON.parse(data.fieldsJson || '[]');
cfFields = parsed.map(f => ({
name: f.name ?? '',
label: f.label ?? '',
unit: f.unit ?? '',
defaultValue: f.defaultValue ?? 0
}));
} catch { }
cfRenderFields();
cfToggleRateFields();
if (data.verificationResult != null) {
const el = document.getElementById('cfTestResult');
el.textContent = `AI test = ${Number(data.verificationResult).toFixed(4)}`;
el.className = 'fw-bold text-success';
}
} catch (e) {
showCfError('AI request failed: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-stars"></i> Generate';
}
};
// ── Save ──────────────────────────────────────────────────────────────────
window.cfSave = async function () {
const id = parseInt(document.getElementById('cfId').value) || 0;
const name = document.getElementById('cfName').value.trim();
if (!name) { showCfError('Name is required.'); return; }
const formula = document.getElementById('cfFormula').value.trim();
if (!formula) { showCfError('Formula is required.'); return; }
const fieldErrors = cfFields.map((f, i) => ({ i, err: cfValidateFieldName(f.name) })).filter(x => x.err);
if (fieldErrors.length) {
fieldErrors.forEach(x => cfValidateFieldNameInput(x.i, cfFields[x.i].name));
showCfError(`Fix field name errors before saving: ${fieldErrors[0].err}`);
return;
}
const names = cfFields.map(f => f.name);
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
if (dupes.length) { showCfError(`Duplicate field name: "${dupes[0]}"`); return; }
const dto = {
id,
name,
description: document.getElementById('cfDescription').value.trim() || null,
outputMode: document.getElementById('cfOutputMode').value,
fieldsJson: JSON.stringify(cfFields),
formula,
defaultRate: parseFloat(document.getElementById('cfDefaultRate').value) || null,
rateLabel: document.getElementById('cfRateLabel').value.trim() || null,
notes: document.getElementById('cfNotes').value.trim() || null,
displayOrder: 0,
isActive: document.getElementById('cfIsActive').checked
};
const url = id ? '/CompanySettings/UpdateCustomItemTemplate' : '/CompanySettings/CreateCustomItemTemplate';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgeryToken() },
body: JSON.stringify(dto)
});
const data = await res.json();
if (!data.success) { showCfError(data.message ?? 'Save failed.'); return; }
const newId = data.id ?? id;
// Upload diagram if a new file was chosen
const diagramFile = document.getElementById('cfDiagramFile').files[0];
if (diagramFile && newId) {
const fd = new FormData();
fd.append('templateId', newId);
fd.append('diagramFile', diagramFile);
await fetch('/CompanySettings/UploadTemplateDiagram', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: fd
});
}
bootstrap.Modal.getInstance(document.getElementById('cfModal'))?.hide();
cfLoadTemplates();
} catch (e) {
showCfError('Save request failed: ' + e.message);
}
};
// ── Delete ────────────────────────────────────────────────────────────────
window.cfDelete = async function (id, name) {
if (!confirm(`Delete template "${name}"? This cannot be undone.`)) return;
try {
const fd = new FormData();
fd.append('id', id);
const res = await fetch('/CompanySettings/DeleteCustomItemTemplate', {
method: 'POST',
headers: { 'RequestVerificationToken': getAntiForgeryToken() },
body: fd
});
const data = await res.json();
if (!data.success) { showCfError(data.message ?? 'Delete failed.'); return; }
cfLoadTemplates();
} catch (e) {
showCfError('Delete failed: ' + e.message);
}
};
// ── Helpers ───────────────────────────────────────────────────────────────
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function showCfError(msg) {
const el = document.getElementById('errorToastMessage');
if (el) {
el.textContent = msg;
const toast = document.getElementById('errorToast');
if (toast) new bootstrap.Toast(toast).show();
} else {
alert(msg);
}
}
function getAntiForgeryToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
// ── Walkthrough ───────────────────────────────────────────────────────────
let cfWtStep = 0;
const cfWtSteps = [
{
title: 'What are Formula Templates?',
icon: 'bi-lightbulb text-warning',
html: `
<p>Some items &mdash; roof curbs, electrical enclosures, welded frames &mdash; have prices that depend on
<strong>exact measurements</strong> rather than estimated surface area.</p>
<p>A <strong>Formula Template</strong> lets you define a reusable calculation once in Company Settings.
When a staff member adds that item to a quote or job, they just fill in the measurements and the price
calculates automatically &mdash; no mental math, no spreadsheets.</p>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Templates appear as a <strong>"Custom Formula Item"</strong> option in the quote and job item wizard,
but only when at least one active template exists.
</div>`
},
{
title: 'Output Modes',
icon: 'bi-toggle2-on text-primary',
html: `
<p>When you create a template, you choose how the formula result is used:</p>
<div class="table-responsive">
<table class="table table-bordered table-sm mb-3">
<thead class="table-light">
<tr><th style="width:30%">Mode</th><th>Formula produces&hellip;</th><th>How it&rsquo;s priced</th></tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-primary">Fixed Rate</span></td>
<td>A <strong>dollar amount</strong></td>
<td>Used directly as the item&rsquo;s unit price. Best for items priced by a custom rate (e.g. $/sqft of surface).</td>
</tr>
<tr>
<td><span class="badge bg-success">Surface Area</span></td>
<td><strong>Square footage</strong></td>
<td>Fed into the standard coating engine and priced using your operating cost rates &mdash; just like any other coated item.</td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-secondary mb-0">
<i class="bi bi-star me-1"></i>
<strong>Most shops start with Fixed Rate.</strong> It gives you full control over the per-unit price
and is the easiest to reason about.
</div>`
},
{
title: 'Step 1 &mdash; Define Your Fields',
icon: 'bi-input-cursor-text text-success',
html: `
<p>Fields are the <strong>measurements your staff will fill in</strong> when they use the template.
Each field becomes an input box in the item wizard.</p>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card border-success h-100">
<div class="card-header bg-success text-white py-1 small fw-semibold">What you set up</div>
<div class="card-body py-2 small">
<table class="table table-sm mb-0">
<tr><th>Variable name</th><td><code>length_in</code></td></tr>
<tr><th>Label</th><td>Length (inches)</td></tr>
<tr><th>Default</th><td>24</td></tr>
</table>
<table class="table table-sm mb-0 mt-2">
<tr><th>Variable name</th><td><code>width_in</code></td></tr>
<tr><th>Label</th><td>Width (inches)</td></tr>
<tr><th>Default</th><td>12</td></tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-primary h-100">
<div class="card-header bg-primary text-white py-1 small fw-semibold">What staff sees in the wizard</div>
<div class="card-body py-2">
<label class="form-label small mb-1">Length (inches)</label>
<input type="number" class="form-control form-control-sm mb-2" value="24" readonly>
<label class="form-label small mb-1">Width (inches)</label>
<input type="number" class="form-control form-control-sm" value="12" readonly>
</div>
</div>
</div>
</div>
<div class="alert alert-warning mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
Variable names must <strong>start with a letter</strong> and contain only letters, digits, or underscores &mdash; no spaces.
Good: <code>length_in</code> &nbsp; Bad: <code>length in</code> or <code>1length</code>
</div>`
},
{
title: 'Step 2 &mdash; Write the Formula',
icon: 'bi-braces text-info',
html: `
<p>The formula is a math expression using your <strong>field variable names</strong> plus the reserved
variable <code>rate</code> (pre-filled from the template&rsquo;s Default Rate).</p>
<p class="fw-semibold mb-1">Example &mdash; flat panel price (inches &rarr; sqft &rarr; dollars):</p>
<pre class="bg-light border rounded p-3 mb-3" style="font-size:.9rem">length_in * width_in / 144 * rate</pre>
<p class="fw-semibold mb-1">Supported operations:</p>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<ul class="mb-0 small">
<li><code>+ &nbsp;- &nbsp;* &nbsp;/</code> &mdash; basic math</li>
<li><code>Pow(base, exp)</code> &mdash; exponents</li>
<li><code>Sqrt(x)</code> &mdash; square root</li>
</ul>
</div>
<div class="col-sm-6">
<ul class="mb-0 small">
<li><code>Round(x, digits)</code> &mdash; rounding</li>
<li><code>Abs(x)</code> &mdash; absolute value</li>
<li><code>Max(a,b) &nbsp;Min(a,b)</code></li>
</ul>
</div>
</div>
<div class="alert alert-info mb-0 small">
<i class="bi bi-info-circle me-1"></i>
<code>rate</code> is always available &mdash; you set it as the <strong>Default Rate</strong> on the template
and staff can override it per-use. Don&rsquo;t create a field called <code>rate</code>.
</div>`
},
{
title: 'Step 3 &mdash; Test Before Saving',
icon: 'bi-play-circle text-success',
html: `
<p>Before saving, use the <strong>Run</strong> button to verify your formula evaluates correctly
using each field&rsquo;s default value.</p>
<div class="card border-success mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col">
<label class="form-label small mb-1 fw-semibold">Formula</label>
<input type="text" class="form-control form-control-sm font-monospace" value="length_in * width_in / 144 * rate" readonly>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm" disabled><i class="bi bi-play-fill me-1"></i>Run</button>
</div>
<div class="col-auto">
<span class="fw-bold text-success">&nbsp;= 7.0000</span>
</div>
</div>
<div class="text-muted small mt-1">Using defaults: length_in=24, width_in=12, rate=3.50 &rarr; 24&times;12/144&times;3.50 = $7.00</div>
</div>
</div>
<p class="mb-0">If the formula has an error (typo, missing variable, bad syntax) the result will
show in red with a description of the problem. Fix it before saving.</p>`
},
{
title: 'Example &mdash; 6-Sided Box',
icon: 'bi-box text-secondary',
html: `
<p>A roof curb or electrical enclosure can be priced by calculating its outer surface area.</p>
<div class="card border mb-3">
<div class="card-header py-2 fw-semibold small">Template Setup</div>
<div class="card-body py-2 small">
<div class="row g-3">
<div class="col-md-5">
<strong>Fields</strong>
<table class="table table-sm mb-0 mt-1">
<thead class="table-light"><tr><th>Variable</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>l_in</code></td><td>Length (in)</td><td>24</td></tr>
<tr><td><code>w_in</code></td><td>Width (in)</td><td>24</td></tr>
<tr><td><code>h_in</code></td><td>Height (in)</td><td>12</td></tr>
</tbody>
</table>
</div>
<div class="col-md-7">
<strong>Formula</strong> <span class="text-muted">(Fixed Rate, rate = 3.50)</span>
<pre class="bg-light border rounded p-2 mt-1 mb-2" style="font-size:.8rem">2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate</pre>
<strong>What it does:</strong> Calculates total outer surface area of all 6 faces
in square inches, converts to square feet (&divide;144), multiplies by rate.
<div class="mt-2 text-success fw-semibold">24&times;24&times;12 box at $3.50/sqft &rarr; $28.00</div>
</div>
</div>
</div>
</div>
<p class="mb-0 text-muted small">
<i class="bi bi-lightbulb me-1"></i>
You can add a diagram image to the template &mdash; staff will see it in the wizard as a reference
while entering measurements.
</p>`
},
{
title: 'Example &mdash; Cylinder',
icon: 'bi-vinyl text-secondary',
html: `
<p>Round parts like pipe ends or cylindrical housings use a slightly different formula.</p>
<div class="card border mb-3">
<div class="card-header py-2 fw-semibold small">Template Setup</div>
<div class="card-body py-2 small">
<div class="row g-3">
<div class="col-md-5">
<strong>Fields</strong>
<table class="table table-sm mb-0 mt-1">
<thead class="table-light"><tr><th>Variable</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>d_in</code></td><td>Diameter (in)</td><td>12</td></tr>
<tr><td><code>h_in</code></td><td>Height (in)</td><td>18</td></tr>
</tbody>
</table>
</div>
<div class="col-md-7">
<strong>Formula</strong> <span class="text-muted">(Fixed Rate, rate = 3.50)</span>
<pre class="bg-light border rounded p-2 mt-1 mb-2" style="font-size:.75rem">(3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate</pre>
<strong>What it does:</strong> Lateral surface area (circumference &times; height)
plus two circular end caps, converted to sqft, multiplied by rate.
<div class="mt-2 text-success fw-semibold">12&Prime; dia &times; 18&Prime; tall at $3.50/sqft &rarr; ~$10.21</div>
</div>
</div>
</div>
</div>
<div class="alert alert-success mb-0">
<i class="bi bi-check-circle me-2"></i>
<strong>You&rsquo;re ready to create your first template!</strong>
Click <strong>Get Started</strong> below to open the template editor, or close this guide
and click <strong>New Template</strong> any time.
</div>`
}
];
window.cfShowWalkthrough = function () {
cfWtStep = 0;
cfRenderWtStep();
new bootstrap.Modal(document.getElementById('cfWalkthroughModal')).show();
localStorage.setItem('cfWalkthroughSeen', '1');
};
window.cfWalkthroughNav = function (dir) {
const next = cfWtStep + dir;
if (next < 0) return;
if (next >= cfWtSteps.length) {
bootstrap.Modal.getInstance(document.getElementById('cfWalkthroughModal'))?.hide();
cfShowCreate();
return;
}
cfWtStep = next;
cfRenderWtStep();
};
function cfRenderWtStep() {
const step = cfWtSteps[cfWtStep];
const total = cfWtSteps.length;
const isLast = cfWtStep === total - 1;
// Dots
document.getElementById('cfWalkthroughDots').innerHTML = cfWtSteps.map((_, i) =>
`<span style="width:10px;height:10px;border-radius:50%;display:inline-block;cursor:pointer;
background:${i === cfWtStep ? '#0d6efd' : '#dee2e6'}"
onclick="cfWtJump(${i})" title="Step ${i+1}"></span>`
).join('');
// Content
document.getElementById('cfWalkthroughContent').innerHTML = `
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi ${step.icon} fs-4"></i>
<h6 class="mb-0 fw-semibold">${step.title}</h6>
<span class="ms-auto text-muted small">${cfWtStep + 1} of ${total}</span>
</div>
${step.html}`;
// Buttons
document.getElementById('cfWtPrevBtn').style.visibility = cfWtStep === 0 ? 'hidden' : 'visible';
const nextBtn = document.getElementById('cfWtNextBtn');
if (isLast) {
nextBtn.innerHTML = '<i class="bi bi-rocket-takeoff me-1"></i>Get Started';
nextBtn.className = 'btn btn-success';
} else {
nextBtn.innerHTML = 'Next<i class="bi bi-arrow-right ms-1"></i>';
nextBtn.className = 'btn btn-primary';
}
}
window.cfWtJump = function (i) {
cfWtStep = i;
cfRenderWtStep();
};
})();
+228 -14
View File
@@ -17,7 +17,7 @@ let quoteItems = []; // Array of item objects matching CreateQuoteItemDto shap
const wz = { // Wizard state
step: 1,
editIndex: -1, // -1 = new item; >= 0 = editing
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai'
itemType: null, // 'product' | 'calculated' | 'generic' | 'labor' | 'ai' | 'formula'
data: {}, // Collected field values
ai: { // AI-specific wizard state
phase: 'upload', // 'upload' | 'loading' | 'followup' | 'result'
@@ -126,11 +126,12 @@ function openWizard(editIndex = -1) {
if (editIndex >= 0) {
const item = quoteItems[editIndex];
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
wz.itemType = item.isLaborItem ? 'labor'
: item.isSalesItem ? 'sales'
: item.isGenericItem ? 'generic'
: item.catalogItemId ? 'product'
: item.isAiItem ? 'ai'
: item.isCustomFormulaItem ? 'formula'
: 'calculated';
// Pre-fill wizard data from existing item
wz.data = JSON.parse(JSON.stringify(item)); // deep copy
@@ -333,8 +334,18 @@ function renderStep1Html() {
icon: 'bi-shop',
label: 'Retail / Merchandise',
desc: 'Off-the-shelf items — T-shirts, tumblers, apparel, or any product sold at a fixed price.'
},
{
type: 'formula',
icon: 'bi-calculator',
label: 'Custom Formula Item',
desc: 'Use a saved formula template to price complex fabricated items (roof curbs, enclosures, frames).'
}
].filter(t => t.type !== 'ai' || pageMeta.aiPhotoQuotesEnabled !== false);
].filter(t => {
if (t.type === 'ai') return pageMeta.aiPhotoQuotesEnabled !== false;
if (t.type === 'formula') return (pageMeta.customFormulaTemplates || []).length > 0;
return true;
});
return `<div class="row g-3">` +
types.map(t => `
@@ -370,6 +381,7 @@ function renderStep2Html() {
if (wz.itemType === 'labor') return renderLaborFields();
if (wz.itemType === 'ai') return renderAiPhotoFields();
if (wz.itemType === 'sales') return renderSalesFields();
if (wz.itemType === 'formula') return renderFormulaFields();
return '<p class="text-danger">Unknown item type.</p>';
}
@@ -809,6 +821,155 @@ document.addEventListener('mousedown', e => {
}
});
// ─── Formula Item Fields ──────────────────────────────────────────────────────
function renderFormulaFields() {
const templates = pageMeta.customFormulaTemplates || [];
if (!templates.length) return '<p class="text-warning">No formula templates found. Create one in Company Settings &rarr; Custom Formulas.</p>';
const selectedId = wz.data.customItemTemplateId || null;
const selected = templates.find(t => t.id === selectedId) || null;
// Parse stored field values if re-editing
let storedValues = {};
try { storedValues = JSON.parse(wz.data.formulaFieldValuesJson || '{}'); } catch { storedValues = {}; }
const templateOptions = templates.map(t =>
`<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${escHtml(t.name)}</option>`
).join('');
let fieldsHtml = '';
if (selected) {
let fields = [];
try { fields = JSON.parse(selected.fieldsJson || '[]'); } catch { fields = []; }
const diagramHtml = selected.diagramImagePath
? `<div class="mb-3 text-center">
<img src="${escHtml(selected.diagramImagePath)}" alt="Template diagram"
class="img-fluid rounded border" style="max-height:160px;" />
<div class="form-text">Reference diagram for this template</div>
</div>`
: '';
const rateField = selected.outputMode === 'FixedRate' && selected.defaultRate != null
? `<div class="row g-2 mb-2 align-items-center">
<div class="col-5"><label class="form-label mb-0">${escHtml(selected.rateLabel || 'Rate')}</label></div>
<div class="col-5"><input type="number" id="wz_formula_rate" class="form-control form-control-sm"
step="0.01" value="${storedValues['rate'] ?? selected.defaultRate}" /></div>
<div class="col-2 text-muted small">${escHtml(selected.rateLabel || '')}</div>
</div>`
: '';
fieldsHtml = `
${diagramHtml}
${rateField}
${fields.map(f => `
<div class="row g-2 mb-2 align-items-center">
<div class="col-5"><label class="form-label mb-0">${escHtml(f.label || f.name)}</label></div>
<div class="col-5">
<input type="number" id="wz_fld_${escHtml(f.name)}" class="form-control form-control-sm"
step="any" value="${storedValues[f.name] ?? f.defaultValue ?? ''}"
onchange="wzFormulaRecalc()" />
</div>
<div class="col-2 text-muted small">${escHtml(f.unit || '')}</div>
</div>`).join('')}
<div class="mt-3 p-2 border rounded bg-light d-flex align-items-center gap-3">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="wzFormulaRecalc()">
<i class="bi bi-calculator"></i> Calculate
</button>
<span id="wz_formula_result" class="fw-bold fs-5"></span>
<span class="text-muted small">${selected.outputMode === 'FixedRate' ? 'total price' : 'sq ft'}</span>
</div>`;
}
return `
<div class="mb-3">
<label class="form-label">Formula Template <span class="text-danger">*</span></label>
<select id="wz_formulaTemplate" class="form-select" onchange="wzFormulaTemplateChanged()">
<option value="">-- Select a template --</option>
${templateOptions}
</select>
<div id="err_formulaTemplate" class="text-danger small mt-1 d-none">Please select a template.</div>
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<input type="text" id="wz_formula_description" class="form-control"
value="${escHtml(wz.data.description || (selected ? selected.name : ''))}" />
</div>
<div class="mb-2 row g-2">
<div class="col-4">
<label class="form-label">Quantity</label>
<input type="number" id="wz_formula_qty" class="form-control" value="${wz.data.quantity || 1}" min="1" />
</div>
</div>
<div id="wz_formula_fields">${fieldsHtml}</div>
<div id="err_formulaCalc" class="text-danger small mt-1 d-none">Please calculate the formula first.</div>`;
}
window.wzFormulaTemplateChanged = function () {
const id = parseInt(document.getElementById('wz_formulaTemplate')?.value);
wz.data.customItemTemplateId = isNaN(id) ? null : id;
wz.data.formulaResult = null;
document.getElementById('wz_formula_fields').innerHTML = renderFormulaFieldInputs();
};
function renderFormulaFieldInputs() {
const templates = pageMeta.customFormulaTemplates || [];
const selected = templates.find(t => t.id === wz.data.customItemTemplateId);
if (!selected) return '';
// Re-render the inner fields portion
const tmp = renderFormulaFields();
// Extract just the fields section by re-rendering with selected
const div = document.createElement('div');
div.innerHTML = tmp;
return div.querySelector('#wz_formula_fields')?.innerHTML || '';
}
window.wzFormulaRecalc = async function () {
const templates = pageMeta.customFormulaTemplates || [];
const selected = templates.find(t => t.id === wz.data.customItemTemplateId);
if (!selected) return;
let fields = [];
try { fields = JSON.parse(selected.fieldsJson || '[]'); } catch { fields = []; }
const variables = {};
fields.forEach(f => {
const el = document.getElementById(`wz_fld_${f.name}`);
variables[f.name] = el ? (parseFloat(el.value) || 0) : (f.defaultValue || 0);
});
const rateEl = document.getElementById('wz_formula_rate');
if (rateEl) variables['rate'] = parseFloat(rateEl.value) || 0;
try {
const res = await fetch(pageMeta.formulaEvalUrl || '/CompanySettings/EvaluateFormula', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getRequestVerificationToken() },
body: JSON.stringify({ formula: selected.formula, variablesJson: JSON.stringify(variables) })
});
const data = await res.json();
const el = document.getElementById('wz_formula_result');
if (data.success && el) {
el.textContent = selected.outputMode === 'FixedRate'
? `$${Number(data.result).toFixed(2)}`
: `${Number(data.result).toFixed(3)} sq ft`;
el.className = 'fw-bold fs-5 text-success';
wz.data.formulaResult = data.result;
wz.data.formulaVariables = variables;
} else if (el) {
el.textContent = data.error || 'Error';
el.className = 'fw-bold text-danger';
}
} catch (e) {
const el = document.getElementById('wz_formula_result');
if (el) { el.textContent = 'Request failed'; el.className = 'fw-bold text-danger'; }
}
};
function getRequestVerificationToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
function renderAiPhotoFields() {
const existingPhotoHtml = wz.ai.tempIds.map((tid, i) => {
const previewUrl = wz.ai.previewUrls[i] || '';
@@ -2510,6 +2671,47 @@ function collectStep2() {
wz.data.isSalesItem = true;
}
if (wz.itemType === 'formula') {
const templateId = parseInt(document.getElementById('wz_formulaTemplate')?.value);
if (!templateId) {
document.getElementById('err_formulaTemplate')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_formulaTemplate')?.classList.add('d-none');
wz.data.customItemTemplateId = templateId;
}
if (wz.data.formulaResult == null) {
document.getElementById('err_formulaCalc')?.classList.remove('d-none');
valid = false;
} else {
document.getElementById('err_formulaCalc')?.classList.add('d-none');
}
if (valid) {
const templates = pageMeta.customFormulaTemplates || [];
const tmpl = templates.find(t => t.id === templateId);
const qty = parseInt(document.getElementById('wz_formula_qty')?.value) || 1;
const descEl = document.getElementById('wz_formula_description');
wz.data.quantity = qty;
wz.data.description = descEl?.value?.trim() || (tmpl?.name ?? 'Custom Formula Item');
wz.data.isCustomFormulaItem = true;
wz.data.formulaFieldValuesJson = JSON.stringify(wz.data.formulaVariables || {});
if (tmpl?.outputMode === 'FixedRate') {
wz.data.manualUnitPrice = parseFloat(wz.data.formulaResult) || 0;
wz.data.surfaceAreaSqFt = 0;
wz.data.estimatedMinutes = 0;
} else {
// SurfaceAreaSqFt mode — formula produced sq ft; standard engine prices it
wz.data.surfaceAreaSqFt = parseFloat(wz.data.formulaResult) || 0;
wz.data.manualUnitPrice = null;
wz.data.estimatedMinutes = 0;
}
}
}
if (wz.itemType === 'ai') {
if (!wz.ai.accepted || !wz.ai.result) {
document.getElementById('ai_acceptError')?.classList.remove('d-none');
@@ -2663,7 +2865,7 @@ function buildItemFromWizard() {
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
estimatedMinutes: d.estimatedMinutes || 0,
catalogItemId: d.catalogItemId || null,
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem || d.isCustomFormulaItem ? (d.manualUnitPrice ?? null) : null),
powderCostOverride: d.powderCostOverride ?? null,
isGenericItem: !!d.isGenericItem,
isLaborItem: !!d.isLaborItem,
@@ -2671,6 +2873,9 @@ function buildItemFromWizard() {
salesCatalogItemId: d.salesCatalogItemId || null,
sku: d.sku || null,
isAiItem: isAi,
isCustomFormulaItem: !!d.isCustomFormulaItem,
customItemTemplateId: d.customItemTemplateId || null,
formulaFieldValuesJson: d.formulaFieldValuesJson || null,
requiresSandblasting: false,
requiresMasking: false,
notes: d.notes || null,
@@ -2711,12 +2916,13 @@ function renderAllCards() {
}
function buildCardHtml(item, i) {
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const typeInfo = item.isLaborItem ? { label: 'Labor', cls: 'info', icon: 'bi-person-gear' }
: item.isGenericItem ? { label: 'Flat-Rate', cls: 'warning', icon: 'bi-tag' }
: item.isSalesItem ? { label: 'Merchandise', cls: 'success', icon: 'bi-shop' }
: item.catalogItemId ? { label: 'Product', cls: 'primary', icon: 'bi-bag-check' }
: item.isAiItem ? { label: 'AI Quoted', cls: 'secondary', icon: 'bi-robot' }
: item.isCustomFormulaItem ? { label: 'Formula', cls: 'purple', icon: 'bi-calculator' }
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
const coatCount = item.coats?.length || 0;
const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
@@ -2855,6 +3061,11 @@ function writeHiddenFields() {
if (item.isAiItem) fields.push(h(p + '.IsAiItem', 'true'));
if (item.aiTags) fields.push(h(p + '.AiTags', item.aiTags));
if (item.aiPredictionId != null) fields.push(h(p + '.AiPredictionId', item.aiPredictionId));
if (item.isCustomFormulaItem) {
fields.push(h(p + '.IsCustomFormulaItem', 'true'));
if (item.customItemTemplateId != null) fields.push(h(p + '.CustomItemTemplateId', item.customItemTemplateId));
if (item.formulaFieldValuesJson) fields.push(h(p + '.FormulaFieldValuesJson', item.formulaFieldValuesJson));
}
(item.prepServices || []).forEach((ps, pi) => {
const pp = `${p}.PrepServices[${pi}]`;
@@ -3410,6 +3621,9 @@ function loadItemsFromTemplate(templateItems) {
isGenericItem: !!ti.isGenericItem,
isLaborItem: !!ti.isLaborItem,
isAiItem: false,
isCustomFormulaItem: false,
customItemTemplateId: null,
formulaFieldValuesJson: null,
requiresSandblasting: !!ti.requiresSandblasting,
requiresMasking: !!ti.requiresMasking,
notes: null,
@@ -217,6 +217,7 @@ public class JobItemAssemblyServiceTests
nameof(QuoteItem.IsLaborItem),
nameof(QuoteItem.IsSalesItem),
nameof(QuoteItem.IsAiItem),
nameof(QuoteItem.IsCustomFormulaItem),
};
foreach (var flag in requiredPricingFlags)