Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex fabricated items (roof curbs, enclosures, welded frames). Templates support two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt (formula yields sq ft fed into the standard coating engine). Includes: - CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo - IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService overloads and all existingItemsData JSON projections + pageMeta blocks - ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula generator (natural language + optional diagram image) and NCalc2 evaluator - CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete, UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi - Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js - item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields - Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details - Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action, HelpKnowledgeBase entry; 225/225 unit tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+10780
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");
|
||||
|
||||
@@ -6711,7 +6796,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300),
|
||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6722,7 +6807,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313),
|
||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6733,7 +6818,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315),
|
||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -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 (sqin→sqft) 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,174 @@ 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 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,21 @@ 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)
|
||||
Example formula (box, inches): 2*(l*w + l*h + w*h) / 144 * rate
|
||||
Help article: Help → Custom Formula Item Templates
|
||||
""";
|
||||
|
||||
}
|
||||
|
||||
@@ -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,143 @@
|
||||
</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>
|
||||
<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 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…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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 — formula → $ amount</option>
|
||||
<option value="SurfaceAreaSqFt">Surface Area — formula → 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">NCalc expression. Available variables: field names + <code>rate</code>.</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 +3431,17 @@
|
||||
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
|
||||
document.querySelector('[data-bs-target="#custom-formulas"]').addEventListener('shown.bs.tab', () => {
|
||||
if (!window._cfLoaded) { cfLoadTemplates(); window._cfLoaded = true; }
|
||||
});
|
||||
</script>
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
@{
|
||||
ViewData["Title"] = "Custom Formula Item Templates — 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 — roof curbs, electrical enclosures, welded frames — 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 → 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’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 → 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> — 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’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">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’s Default Rate.</li>
|
||||
</ul>
|
||||
<p class="mb-0">Examples:</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>2*(l*w + l*h + w*h) / 144 * rate</code> — box surface area (inches → sqft → dollars)</li>
|
||||
<li><code>Pow(diameter_in / 2, 2) * 3.14159 / 144 * rate</code> — circular face area</li>
|
||||
<li><code>(l_ft * w_ft) * rate</code> — flat panel in feet</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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,382 @@
|
||||
// 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…</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();
|
||||
};
|
||||
|
||||
function cfRenderFields() {
|
||||
const el = document.getElementById('cfFieldsList');
|
||||
if (!cfFields.length) {
|
||||
el.innerHTML = '<p class="text-muted small">No fields yet.</p>';
|
||||
return;
|
||||
}
|
||||
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"
|
||||
placeholder="var_name" value="${escHtml(f.name)}"
|
||||
oninput="cfFields[${i}].name=this.value" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Label" value="${escHtml(f.label)}"
|
||||
oninput="cfFields[${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="cfFields[${i}].unit=this.value" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
placeholder="Default" value="${f.defaultValue ?? ''}"
|
||||
oninput="cfFields[${i}].defaultValue=parseFloat(this.value)||0" />
|
||||
</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('');
|
||||
}
|
||||
|
||||
// ── 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…';
|
||||
|
||||
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 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 ?? '';
|
||||
}
|
||||
})();
|
||||
@@ -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 → 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');
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user