Compare commits

..

7 Commits

Author SHA1 Message Date
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
spouliot c5c1244177 Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages
- Live pricing summary and Job Costing card updates on save
- PatchItem legacy fallback for jobs without PricingBreakdownJson
- GetCostingBreakdown revenue from FinalPrice (not invoice total)
- Help docs: Inline Price Editing sections added to all three detail pages
- AI knowledge base updated with inline editing and costing revenue behavior
- AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs
- Resolve conflict in Payment/Index.cshtml (em dash entity style)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:35:29 -04:00
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
131 changed files with 384 additions and 46534 deletions
@@ -1,174 +0,0 @@
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; }
}
@@ -20,7 +20,7 @@ public class EquipmentDto
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
public string? Location { get; set; } public string? Location { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
public int? DaysUntilMaintenance { get; set; } public int? DaysUntilMaintenance { get; set; }
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")] [Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
[Display(Name = "Recommended Maintenance Interval (Days)")] [Display(Name = "Recommended Maintenance Interval (Days)")]
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
[Display(Name = "Last Maintenance Date")] [Display(Name = "Last Maintenance Date")]
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
@@ -325,11 +325,7 @@ public class JobItemDto
public bool IsGenericItem { get; set; } public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; } public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; } public bool IsSalesItem { get; set; }
public bool IsAiItem { get; set; }
public string? Sku { 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<JobItemCoatDto> Coats { get; set; } = new();
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new(); public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
} }
@@ -490,7 +486,6 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; } public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; } public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
@@ -516,11 +511,6 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; } public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Rework job creation (opt-in)
public bool CreateReworkJob { get; set; }
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
} }
public class UpdateReworkRecordDto public class UpdateReworkRecordDto
@@ -475,11 +475,6 @@ public class QuoteItemDto
public bool IsAiItem { get; set; } 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 // Cost breakdown snapshot
public decimal ItemMaterialCost { get; set; } public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; } public decimal ItemLaborCost { get; set; }
@@ -564,11 +559,6 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items) // ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
public int? AiPredictionId { get; set; } 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; }
} }
// ============================================================================ // ============================================================================
@@ -125,8 +125,6 @@ public class CreateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
// ============================================================================ // ============================================================================
@@ -211,6 +209,4 @@ public class UpdateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
@@ -1,22 +0,0 @@
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);
}
@@ -1,41 +0,0 @@
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;
}
}
}
@@ -196,9 +196,7 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription, .ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null)) opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.ForMember(dest => dest.ReworkJobNumber, .ForMember(dest => dest.ReworkJobNumber,
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null)) opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
.ForMember(dest => dest.ReworkPricingType,
opt => opt.MapFrom(src => src.ReworkPricingType));
// Job → JobDto (rework fields) // Job → JobDto (rework fields)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller) // (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -159,7 +159,6 @@ public class QuoteProfile : Profile
.ReverseMap() .ReverseMap()
.ForMember(dest => dest.Quote, opt => opt.Ignore()) .ForMember(dest => dest.Quote, opt => opt.Ignore())
.ForMember(dest => dest.CatalogItem, 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.Coats, opt => opt.Ignore())
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) .ForMember(dest => dest.PrepServices, opt => opt.Ignore())
.ForMember(dest => dest.CompanyId, opt => opt.Ignore()) .ForMember(dest => dest.CompanyId, opt => opt.Ignore())
@@ -181,7 +180,6 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore()) .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.CompanyId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -16,7 +16,6 @@
<PackageReference Include="FluentValidation" Version="11.11.0" /> <PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.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" /> <PackageReference Include="QuestPDF" Version="2024.12.3" />
@@ -53,10 +53,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -160,10 +157,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -265,10 +259,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -362,9 +353,6 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity, Complexity = seed.Complexity,
AiTags = seed.AiTags, AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId, AiPredictionId = seed.AiPredictionId,
IsCustomFormulaItem = seed.IsCustomFormulaItem,
CustomItemTemplateId = seed.CustomItemTemplateId,
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -492,9 +480,6 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; } public string? Complexity { get; init; }
public string? AiTags { get; init; } public string? AiTags { get; init; }
public int? AiPredictionId { 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> /// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -288,24 +288,6 @@ 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. // Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue) if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{ {
@@ -130,14 +130,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return; 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.CatalogItemId.HasValue)
{ {
if (itemDto.Coats != null && itemDto.Coats.Any()) if (itemDto.Coats != null && itemDto.Coats.Any())
@@ -251,9 +243,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem, IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags, AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId, AiPredictionId = itemDto.AiPredictionId,
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
CustomItemTemplateId = itemDto.CustomItemTemplateId,
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -1,38 +0,0 @@
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; }
}
+1 -1
View File
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
public string? Location { get; set; } public string? Location { get; set; }
// Maintenance Information // Maintenance Information
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
@@ -12,5 +12,4 @@ public class InventoryCategoryLookup : BaseEntity
// Relationships // Relationships
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>(); public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
} }
@@ -52,14 +52,6 @@ public class JobItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { 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 // Relationships
public virtual Job Job { get; set; } = null!; public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
@@ -56,14 +56,6 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { 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 // Relationships
public virtual Quote Quote { get; set; } = null!; public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
@@ -31,9 +31,6 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Pricing attribution for the linked rework job (null on pre-existing records)
public ReworkPricingType? ReworkPricingType { get; set; }
// ── Resolution ──────────────────────────────────────────────────────────── // ── Resolution ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open; public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; } public ReworkResolution? Resolution { get; set; }
@@ -45,7 +45,6 @@ public class Vendor : BaseEntity
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>(); public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>(); public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; } public virtual Account? DefaultExpenseAccount { get; set; }
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
} }
public class InventoryTransaction : BaseEntity public class InventoryTransaction : BaseEntity
-8
View File
@@ -144,14 +144,6 @@ public enum ReworkResolution
NoActionRequired = 4 NoActionRequired = 4
} }
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
public enum ReworkPricingType
{
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
CustomerFull = 2 // Customer caused it; full original pricing applies
}
public enum BugReportStatus public enum BugReportStatus
{ {
New = 0, New = 0,
@@ -155,9 +155,6 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Customer Intake Kiosk // Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; } IRepository<KioskSession> KioskSessions { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
Task<int> SaveChangesAsync(); Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -92,10 +92,4 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day. /// were never completed and rolled past their scheduled day.
/// </summary> /// </summary>
Task<List<Job>> GetOverdueScheduledJobsAsync(); Task<List<Job>> GetOverdueScheduledJobsAsync();
/// <summary>
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
/// </summary>
Task<int> GetReworkJobCountAsync(int originalJobId);
} }
@@ -374,10 +374,6 @@ 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> /// <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; } 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> /// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants. /// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly. /// No global query filter — SuperAdmin controllers query this directly.
@@ -813,15 +809,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(s => s.DefaultExpenseAccountId) .HasForeignKey(s => s.DefaultExpenseAccountId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
modelBuilder.Entity<Vendor>()
.HasMany(v => v.Categories)
.WithMany(c => c.Vendors)
.UsingEntity<Dictionary<string, object>>(
"VendorInventoryCategories",
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
// Bill → APAccount (no cascade to avoid cycles) // Bill → APAccount (no cascade to avoid cycles)
modelBuilder.Entity<Bill>() modelBuilder.Entity<Bill>()
.HasOne(b => b.APAccount) .HasOne(b => b.APAccount)
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddReworkPricingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ReworkPricingType",
table: "ReworkRecords",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReworkPricingType",
table: "ReworkRecords");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddVendorCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VendorInventoryCategories",
columns: table => new
{
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
table.ForeignKey(
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
column: x => x.InventoryCategoryLookupId,
principalTable: "InventoryCategoryLookups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VendorInventoryCategories_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
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));
migrationBuilder.CreateIndex(
name: "IX_VendorInventoryCategories_VendorId",
table: "VendorInventoryCategories",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VendorInventoryCategories");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
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));
}
}
}
@@ -1,79 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MakeMaintenanceIntervalNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
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,80 +2650,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CreditMemoApplications"); 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 => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3119,7 +3045,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("PurchasePrice") b.Property<decimal>("PurchasePrice")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int?>("RecommendedMaintenanceIntervalDays") b.Property<int>("RecommendedMaintenanceIntervalDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("SerialNumber") b.Property<string>("SerialNumber")
@@ -4547,9 +4473,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -4566,18 +4489,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -4641,8 +4558,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("JobId") b.HasIndex("JobId")
.HasDatabaseName("IX_JobItems_JobId"); .HasDatabaseName("IX_JobItems_JobId");
@@ -6796,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6807,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6818,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7345,9 +7260,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -7361,18 +7273,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("EstimatedMinutes") b.Property<int>("EstimatedMinutes")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -7442,8 +7348,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("QuoteId") b.HasIndex("QuoteId")
.HasDatabaseName("IX_QuoteItems_QuoteId"); .HasDatabaseName("IX_QuoteItems_QuoteId");
@@ -8086,9 +7990,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId") b.Property<int?>("ReworkJobId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType") b.Property<int>("ReworkType")
.HasColumnType("int"); .HasColumnType("int");
@@ -8730,21 +8631,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("YearEndCloses"); b.ToTable("YearEndCloses");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.Property<int>("InventoryCategoryLookupId")
.HasColumnType("int");
b.Property<int>("VendorId")
.HasColumnType("int");
b.HasKey("InventoryCategoryLookupId", "VendorId");
b.HasIndex("VendorId");
b.ToTable("VendorInventoryCategories");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -9608,10 +9494,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("CatalogItemId") .HasForeignKey("CatalogItemId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Job", "Job") b.HasOne("PowderCoating.Core.Entities.Job", "Job")
.WithMany("JobItems") .WithMany("JobItems")
.HasForeignKey("JobId") .HasForeignKey("JobId")
@@ -9622,8 +9504,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Job"); b.Navigation("Job");
}); });
@@ -10233,10 +10113,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany() .WithMany()
.HasForeignKey("CatalogItemId"); .HasForeignKey("CatalogItemId");
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
.WithMany("QuoteItems") .WithMany("QuoteItems")
.HasForeignKey("QuoteId") .HasForeignKey("QuoteId")
@@ -10247,8 +10123,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Quote"); b.Navigation("Quote");
}); });
@@ -10495,21 +10369,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
.WithMany()
.HasForeignKey("InventoryCategoryLookupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
{ {
b.Navigation("BillLineItems"); b.Navigation("BillLineItems");
@@ -21,7 +21,6 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" /> <PackageReference Include="Stripe.net" Version="50.4.1" />
@@ -187,14 +187,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync() public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{ {
@@ -123,9 +123,6 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk // Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions; private IRepository<KioskSession>? _kioskSessions;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
// Purchase Orders // Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders; private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems; private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -460,10 +457,6 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions => public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context); _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 // Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates => public IJobTemplateRepository JobTemplates =>
@@ -1,197 +0,0 @@
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,8 +33,6 @@ public class CompanySettingsController : Controller
private readonly IAuditLogService _auditLog; private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
public CompanySettingsController( public CompanySettingsController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -47,9 +45,7 @@ public class CompanySettingsController : Controller
IConfiguration configuration, IConfiguration configuration,
IAuditLogService auditLog, IAuditLogService auditLog,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager, SignInManager<ApplicationUser> signInManager)
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -62,8 +58,6 @@ public class CompanySettingsController : Controller
_auditLog = auditLog; _auditLog = auditLog;
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
} }
/// <summary> /// <summary>
@@ -2968,218 +2962,6 @@ public class CompanySettingsController : Controller
return RedirectToAction(nameof(DeleteAccount)); return RedirectToAction(nameof(DeleteAccount));
} }
} }
// ─── Custom Formula Item Templates ──────────────────────────────────────────
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
return Json(new { success = true, template = dto });
}
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplates()
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId);
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
return Json(new { success = true, templates = dtos });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, id = entity.Id });
}
/// <summary>Updates an existing formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>
/// Uploads a diagram image for a template to blob storage container
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
/// Returns the blob path for storage on the entity.
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
if (diagramFile.Length > 10 * 1024 * 1024)
return Json(new { success = false, message = "Image must be under 10 MB." });
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
using var stream = diagramFile.OpenReadStream();
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
if (!ok)
return Json(new { success = false, message = err });
entity.DiagramImagePath = blobPath;
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true, diagramImagePath = blobPath });
}
/// <summary>
/// Serves a template diagram image from blob storage. The path is tenant-scoped
/// so cross-company access is prevented by checking CompanyId on the template.
/// </summary>
[HttpGet]
public async Task<IActionResult> TemplateDiagram(int templateId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
return NotFound();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Evaluates a NCalc formula with the supplied variable values.
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
/// in the Application/Infrastructure layer.
/// </summary>
[HttpPost]
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
{
var result = _formulaAiService.EvaluateFormula(req);
return Json(result);
}
/// <summary>
/// Calls Claude to generate a formula template from a natural-language description
/// and an optional diagram image uploaded in the same multipart form.
/// </summary>
[HttpPost]
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
{
if (string.IsNullOrWhiteSpace(description))
return Json(new { success = false, error = "Description is required." });
byte[]? imageBytes = null;
string? imageContentType = null;
if (diagramImage is { Length: > 0 })
{
using var ms = new MemoryStream();
await diagramImage.CopyToAsync(ms);
imageBytes = ms.ToArray();
imageContentType = diagramImage.ContentType;
}
var result = await _formulaAiService.GenerateFormulaAsync(
new GenerateFormulaFromAiRequest { Description = description },
imageBytes,
imageContentType);
return Json(result);
}
/// <summary>
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
/// must start with a letter, contain only letters/digits/underscores, and not use the
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
/// Returns an error message string on failure, or null if all names are valid.
/// </summary>
private static string? ValidateTemplateFields(string? fieldsJson)
{
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
List<System.Text.Json.JsonElement>? fields;
try
{
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
}
catch { return "Invalid fields JSON."; }
if (fields == null) return null;
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name))
return "All fields must have a variable name.";
if (name == "rate")
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
if (!nameRegex.IsMatch(name))
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
if (!seen.Add(name))
return $"Duplicate field name \"{name}\".";
}
return null;
}
} }
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body); public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
@@ -125,14 +125,5 @@ namespace PowderCoating.Web.Controllers
{ {
return View(); 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();
}
} }
} }
@@ -946,10 +946,7 @@ public class InventoryController : Controller
if (!string.IsNullOrWhiteSpace(urlMfr)) if (!string.IsNullOrWhiteSpace(urlMfr))
{ {
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart); aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
// The scanned QR URL is always the authoritative product page link — it came if (aiResult.Success && aiResult.SpecPageUrl == null)
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
// whatever LookupAsync returned (which may be a scheme-less path from the template).
if (aiResult.Success)
aiResult.SpecPageUrl = qrUrl; aiResult.SpecPageUrl = qrUrl;
} }
else else
@@ -1495,20 +1492,8 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories)) var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
.OrderBy(v => v.CompanyName).ToList(); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
var categoryVendorMap = new Dictionary<string, List<int>>();
foreach (var v in vendors)
foreach (var cat in v.Categories)
{
var key = cat.Id.ToString();
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
categoryVendorMap[key].Add(v.Id);
}
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
// Load categories from lookup table // Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
@@ -1635,12 +1620,11 @@ public class InventoryController : Controller
/// Renders a print-optimised label for the inventory item containing the QR code, /// Renders a print-optimised label for the inventory item containing the QR code,
/// item name, SKU, and colour. Designed to be printed directly from the browser. /// item name, SKU, and colour. Designed to be printed directly from the browser.
/// </summary> /// </summary>
public async Task<IActionResult> Label(int? id, bool embed = false) public async Task<IActionResult> Label(int? id)
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
if (item == null) return NotFound(); if (item == null) return NotFound();
ViewBag.IsEmbed = embed;
return View(_mapper.Map<InventoryItemDto>(item)); return View(_mapper.Map<InventoryItemDto>(item));
} }
@@ -492,9 +492,6 @@ public class JobsController : Controller
isLaborItem = ji.IsLaborItem, isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem, isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem, isAiItem = ji.IsAiItem,
isCustomFormulaItem = ji.IsCustomFormulaItem,
customItemTemplateId = ji.CustomItemTemplateId,
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
sku = ji.Sku, sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting, requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking, requiresMasking = ji.RequiresMasking,
@@ -1285,9 +1282,6 @@ public class JobsController : Controller
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -1858,25 +1852,6 @@ public class JobsController : Controller
{ {
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); 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 PopulateDropdowns();
await PopulatePrepServicesAsync(companyId); await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId); var costs = await _pricingService.GetOperatingCostsAsync(companyId);
@@ -2432,28 +2407,6 @@ public class JobsController : Controller
}); });
} }
// When a rework job reaches a terminal status, close out the linked ReworkRecord
// on the original job so the shop doesn't have to do it manually.
// Cancelled → WrittenOff; any other terminal → Resolved.
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
{
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
foreach (var rr in linkedRecords)
{
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
continue;
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
? ReworkStatus.WrittenOff
: ReworkStatus.Resolved;
rr.ResolvedDate ??= DateTime.UtcNow;
rr.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
}
if (linkedRecords.Any())
await _unitOfWork.SaveChangesAsync();
}
// Notify customer on status change (only if user opted in) // Notify customer on status change (only if user opted in)
if (request.SendEmail && newStatus != null) if (request.SendEmail && newStatus != null)
{ {
@@ -3009,9 +2962,6 @@ public class JobsController : Controller
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -3204,9 +3154,6 @@ public class JobsController : Controller
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem, IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null), ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost, IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
@@ -3340,13 +3287,6 @@ public class JobsController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
} }
/// <summary> /// <summary>
@@ -3588,13 +3528,10 @@ public class JobsController : Controller
} }
/// <summary> /// <summary>
/// Records a rework event against a job. Optionally creates a linked rework job so the /// Records a rework event against a job item (e.g. defect found during QC).
/// repair can flow through the full shop lifecycle. When creating a rework job: /// Automatically creates a new linked rework Job so the repair work can be tracked
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1) /// through the same job lifecycle. The rework job inherits the original job's customer,
/// - Only items selected by the user are copied (partial rework support) /// oven, and items so the shop has a complete specification to work from.
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
/// - Job starts at the first non-Pending status in the company's workflow
/// </summary> /// </summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto) public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
@@ -3603,41 +3540,66 @@ public class JobsController : Controller
if (job == null) return NotFound(); if (job == null) return NotFound();
var companyId = job.CompanyId; var companyId = job.CompanyId;
Job? reworkJob = null;
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue) // Generate rework job number
{ var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var typeLabel = dto.ReworkType switch var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
{ var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
ReworkType.InternalDefect => "Internal Defect", var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
ReworkType.CustomerWarranty => "Customer Warranty",
ReworkType.CustomerDamage => "Customer Damage",
_ => dto.ReworkType.ToString()
};
var reasonLabel = dto.Reason switch
{
ReworkReason.AdhesionFailure => "Adhesion Failure",
ReworkReason.Contamination => "Contamination",
ReworkReason.ColorMismatch => "Color Mismatch",
ReworkReason.RunsSags => "Runs / Sags",
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
ReworkReason.OvenIssue => "Oven Issue",
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
ReworkReason.HandlingDamage => "Handling Damage",
_ => "Other"
};
var pricingLabel = dto.ReworkPricingType.Value switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
var currentUserId = _userManager.GetUserId(User); var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId); var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = pendingStatus != null ? new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = job.CustomerId,
Description = $"REWORK: {job.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = job.Id,
SpecialInstructions = $"Rework of {job.JobNumber}.",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
} : null;
if (reworkJob != null)
{
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Copy items: specific item if flagged, otherwise all items
var itemsToCopy = dto.JobItemId.HasValue
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
: job.JobItems.ToList();
foreach (var item in itemsToCopy)
{
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.CompleteAsync();
} }
var record = new ReworkRecord var record = new ReworkRecord
@@ -3653,7 +3615,6 @@ public class JobsController : Controller
EstimatedReworkCost = dto.EstimatedReworkCost, EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer, IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes, BillingNotes = dto.BillingNotes,
ReworkPricingType = dto.ReworkPricingType,
ReworkJobId = reworkJob?.Id, ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open, Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId, CompanyId = companyId,
@@ -3663,147 +3624,11 @@ public class JobsController : Controller
await _unitOfWork.ReworkRecords.AddAsync(record); await _unitOfWork.ReworkRecords.AddAsync(record);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Reload with navigation for response
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob); var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
return Json(_mapper.Map<ReworkRecordDto>(saved.First())); return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
} }
/// <summary>
/// Creates a linked rework Job from an existing rework record that was saved without one.
/// Uses sub-number format and applies the specified pricing attribution.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId;
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
var pricingLabel = pricingType switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
var currentUserId = _userManager.GetUserId(User);
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.ReworkPricingType = pricingType;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
/// <summary>
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
/// copies the specified items (with coats and prep services), applies pricing attribution,
/// sets descriptive job description from the rework record data, and auto-records intake
/// (parts are already on hand when rework is logged).
/// Called by both AddReworkRecord and CreateReworkJob.
/// </summary>
private async Task<Job> BuildReworkJobAsync(
Job originalJob,
List<int> itemIds,
ReworkPricingType pricingType,
int companyId,
string reworkDescription,
string? checkedByUserId)
{
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
// First non-Pending status by workflow order
var firstActiveStatus = statuses
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
.OrderBy(s => s.DisplayOrder)
.First();
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job
{
JobNumber = reworkNumber,
CustomerId = originalJob.CustomerId,
Description = reworkDescription,
JobStatusId = firstActiveStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
// Auto-intake: parts are already on hand when rework is logged
IntakeDate = DateTime.UtcNow,
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
IntakeCheckedByUserId = checkedByUserId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
var createdAtUtc = DateTime.UtcNow;
foreach (var item in itemsToCopy)
{
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
// Shop-fault rework jobs are done at no charge
if (pricingType == ReworkPricingType.ShopFault)
{
newItem.UnitPrice = 0;
newItem.ManualUnitPrice = 0;
newItem.TotalPrice = 0;
}
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemCoats.AddAsync(coat);
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
}
// Set intake part count now that items are known
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
// Write a pricing snapshot so the Details page and inline edit both work correctly
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
? 0m
: itemsToCopy.Sum(i => i.TotalPrice);
reworkJob.FinalPrice = itemsSubtotal;
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = itemsSubtotal,
SubtotalAfterDiscount = itemsSubtotal,
Total = itemsSubtotal
});
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
await _unitOfWork.CompleteAsync();
return reworkJob;
}
/// <summary> /// <summary>
/// Updates a rework record's status, resolution notes, cost, and billability. /// Updates a rework record's status, resolution notes, cost, and billability.
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set). /// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
@@ -3855,6 +3680,66 @@ public class JobsController : Controller
return Json(new { success = true }); return Json(new { success = true });
} }
/// <summary>
/// Creates a new rework Job from an existing rework record and links them.
/// The rework job is a lightweight clone of the original job — same customer, description, and
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = reworkRecord.Job;
var companyId = originalJob.CompanyId;
// Load status lookups to find Pending status
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Generate job number
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = originalJob.CustomerId,
Description = $"REWORK: {originalJob.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Link rework record to new job
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ────────────────────────────────────────── // ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary> /// <summary>
@@ -4426,13 +4311,7 @@ public class LogMaterialRequest
public string TransactionType { get; set; } = "JobUsage"; public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; } public string? Notes { get; set; }
} }
public class CreateReworkJobRequest public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
{
public int ReworkRecordId { get; set; }
public List<int>? ItemIds { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public string? Notes { get; set; }
}
public class UpdateWorkerAssignmentRequest public class UpdateWorkerAssignmentRequest
{ {
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
// Calculate next scheduled maintenance // Calculate next scheduled maintenance
if (equipment.RecommendedMaintenanceIntervalDays > 0) if (equipment.RecommendedMaintenanceIntervalDays > 0)
{ {
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value); equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
} }
equipment.UpdatedAt = DateTime.UtcNow; equipment.UpdatedAt = DateTime.UtcNow;
@@ -2429,25 +2429,6 @@ public class QuotesController : Controller
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0); var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan 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 // Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers ViewBag.Customers = customers
@@ -181,7 +181,6 @@ public class VendorsController : Controller
public async Task<IActionResult> Create(bool inline = false) public async Task<IActionResult> Create(bool inline = false)
{ {
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync();
if (inline) if (inline)
return PartialView(new CreateVendorDto()); return PartialView(new CreateVendorDto());
return View(new CreateVendorDto()); return View(new CreateVendorDto());
@@ -208,7 +207,6 @@ public class VendorsController : Controller
return Json(new { success = false, errors }); return Json(new { success = false, errors });
} }
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
@@ -218,12 +216,6 @@ public class VendorsController : Controller
var vendor = _mapper.Map<Vendor>(dto); var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = currentUser!.CompanyId; vendor.CompanyId = currentUser!.CompanyId;
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
vendor.Categories = cats.ToList();
}
await _unitOfWork.Vendors.AddAsync(vendor); await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -255,16 +247,14 @@ public class VendorsController : Controller
try try
{ {
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories); var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
if (vendor == null) if (vendor == null)
{ {
return NotFound(); return NotFound();
} }
var dto = _mapper.Map<UpdateVendorDto>(vendor); var dto = _mapper.Map<UpdateVendorDto>(vendor);
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
catch (Exception ex) catch (Exception ex)
@@ -292,27 +282,18 @@ public class VendorsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
try try
{ {
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories); var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
if (vendor == null) if (vendor == null)
{ {
return NotFound(); return NotFound();
} }
_mapper.Map(dto, vendor); _mapper.Map(dto, vendor);
vendor.Categories.Clear();
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
foreach (var cat in cats) vendor.Categories.Add(cat);
}
await _unitOfWork.Vendors.UpdateAsync(vendor); await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -432,20 +413,6 @@ public class VendorsController : Controller
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so /// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
/// the field is optional — not every vendor needs a default account pre-set. /// the field is optional — not every vendor needs a default account pre-set.
/// </summary> /// </summary>
/// <summary>
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
/// </summary>
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
{
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
.OrderBy(c => c.DisplayOrder)
.ToList();
ViewBag.VendorCategories = cats;
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
}
private async Task PopulateExpenseAccountsAsync() private async Task PopulateExpenseAccountsAsync()
{ {
var accounts = (await _unitOfWork.Accounts.FindAsync( var accounts = (await _unitOfWork.Accounts.FindAsync(
@@ -466,8 +466,7 @@ public static class HelpKnowledgeBase
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item). Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
**QR Code Labels & Mobile Usage Logging:** **QR Code Labels & Mobile Usage Logging:**
- Print a QR label from any item's Details page OR from the QR icon in the Actions column on the Inventory list page click "Print QR Label" a preview modal opens with the label (item name, SKU, colour, finish, and QR code). Click "Print Label" inside the modal to send to your printer without opening a new tab. - Print a QR label from any item's Details page click "Print QR Label" opens a standalone print page with the item name, SKU, colour, and QR code. Print and stick on the bag/bin.
- You can also print from the list page without opening the item: click the QR icon (first button in the action column) next to any row.
- Scan the QR code with a phone camera opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id] - Scan the QR code with a phone camera opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save - On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details" - After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
@@ -1388,25 +1387,5 @@ 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. Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center.
---
**Custom Formula Item Templates (Company Settings Custom Formulas):**
Reusable NCalc pricing formulas for complex fabricated items (roof curbs, electrical enclosures, welded frames). Each template has a list of measurement fields and a formula expression. Two output modes:
- Fixed Rate: formula produces a dollar amount stored as ManualUnitPrice × Qty
- Surface Area: formula produces sq ft standard coating engine prices it
Creating a template: New Template enter name + output mode + fields (name, label, unit, default value) write NCalc formula using field names Run to test optionally upload a diagram image Save
AI Generator: enter description in "AI Formula Generator" box in the template editor Claude suggests formula + fields + mode review and save
Using in wizard: item wizard shows "Custom Formula Item" card if active templates exist choose template template diagram shown for reference enter measurements Calculate verify result continue to coatings/prep steps
Formula variable names: snake_case, letters/digits/underscores only. Reserved variable: "rate" (pre-populated from Default Rate).
NCalc syntax: +, -, *, /, %, Pow(b,e), Abs(x), Round(x,d), Max(a,b), Min(a,b), Sqrt(x)
Common formula patterns (all Fixed Rate, divide inches by 144 to get sqft):
- 6-sided box: fields l_in/w_in/h_in 2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate
- Cylinder: fields d_in/h_in (3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate
- Flat panel: fields l_in/w_in l_in * w_in / 144 * rate
Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button
Help article: Help Custom Formula Item Templates
"""; """;
} }
+3 -6
View File
@@ -219,7 +219,6 @@ builder.Services.AddScoped<IOperationalReportService, OperationalReportService>(
builder.Services.AddScoped<IAiHelpService, AiHelpService>(); builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>(); builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(); builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>(); builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>(); builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
@@ -293,7 +292,6 @@ cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new AccountingProfile()); cfg.AddProfile(new AccountingProfile());
cfg.AddProfile(new PurchaseOrderProfile()); cfg.AddProfile(new PurchaseOrderProfile());
cfg.AddProfile(new PricingTierProfile()); cfg.AddProfile(new PricingTierProfile());
cfg.AddProfile(new CustomItemTemplateProfile());
}, loggerFactory); }, loggerFactory);
return config.CreateMapper(); return config.CreateMapper();
}); });
@@ -673,8 +671,8 @@ System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
// SECURITY: Add security headers middleware // SECURITY: Add security headers middleware
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
// Prevent clickjacking — SAMEORIGIN so our own iframe embeds (QR labels, etc.) still work // Prevent clickjacking
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN"); context.Response.Headers.Append("X-Frame-Options", "DENY");
// Prevent MIME type sniffing // Prevent MIME type sniffing
context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
@@ -701,8 +699,7 @@ app.Use(async (context, next) =>
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:; " + "img-src 'self' data: https:; " +
$"connect-src {cspConnectSrc}; " + $"connect-src {cspConnectSrc}; " +
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " + "frame-src https://js.stripe.com https://hooks.stripe.com");
"frame-ancestors 'self'");
// Referrer Policy - control referrer information // Referrer Policy - control referrer information
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -99,7 +99,7 @@
<tr> <tr>
<td class="fw-bold">@c.ClosedYear</td> <td class="fw-bold">@c.ClosedYear</td>
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td> <td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
<td>@Html.Raw(c.ClosedBy ?? "&mdash;")</td> <td>@(c.ClosedBy ?? "&mdash;")</td>
<td> <td>
@if (c.JournalEntry != null) @if (c.JournalEntry != null)
{ {
@@ -207,16 +207,16 @@
</span> </span>
</td> </td>
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Today > 0 ? row.Today.ToString("N0") : "&mdash;") @(row.Today > 0 ? row.Today.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "&mdash;") @(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "&mdash;") @(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")"> <td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
@Html.Raw(row.AllTime > 0 ? row.AllTime.ToString("N0") : "&mdash;") @(row.AllTime > 0 ? row.AllTime.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")"> <td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
@if (row.PhotoCount > 0) @if (row.PhotoCount > 0)
@@ -46,19 +46,19 @@
<dd class="col-7">@Model.EntityType</dd> <dd class="col-7">@Model.EntityType</dd>
<dt class="col-5 text-muted">Entity ID</dt> <dt class="col-5 text-muted">Entity ID</dt>
<dd class="col-7">@Html.Raw(Model.EntityId ?? "&mdash;")</dd> <dd class="col-7">@(Model.EntityId ?? "&mdash;")</dd>
<dt class="col-5 text-muted">Description</dt> <dt class="col-5 text-muted">Description</dt>
<dd class="col-7">@Html.Raw(Model.EntityDescription ?? "&mdash;")</dd> <dd class="col-7">@(Model.EntityDescription ?? "&mdash;")</dd>
<dt class="col-5 text-muted">User</dt> <dt class="col-5 text-muted">User</dt>
<dd class="col-7">@Model.UserName</dd> <dd class="col-7">@Model.UserName</dd>
<dt class="col-5 text-muted">Company</dt> <dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@Html.Raw(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "&mdash;"))</dd> <dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "&mdash;"))</dd>
<dt class="col-5 text-muted">IP Address</dt> <dt class="col-5 text-muted">IP Address</dt>
<dd class="col-7">@Html.Raw(Model.IpAddress ?? "&mdash;")</dd> <dd class="col-7">@(Model.IpAddress ?? "&mdash;")</dd>
</dl> </dl>
</div> </div>
</div> </div>
@@ -95,8 +95,8 @@
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null; var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
<tr> <tr>
<td class="fw-medium">@key</td> <td class="fw-medium">@key</td>
<td class="text-danger font-monospace">@Html.Raw(oldVal ?? "&mdash;")</td> <td class="text-danger font-monospace">@(oldVal ?? "&mdash;")</td>
<td class="text-success font-monospace">@Html.Raw(newVal ?? "&mdash;")</td> <td class="text-success font-monospace">@(newVal ?? "&mdash;")</td>
</tr> </tr>
} }
} }
@@ -248,7 +248,7 @@
{ {
<tr class="text-muted"> <tr class="text-muted">
<td><code>@ban.IpAddress</code></td> <td><code>@ban.IpAddress</code></td>
<td><small>@Html.Raw(ban.Reason ?? "&mdash;")</small></td> <td><small>@(ban.Reason ?? "&mdash;")</small></td>
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td> <td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
<td> <td>
@if (!ban.IsActive) @if (!ban.IsActive)
@@ -162,7 +162,7 @@
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td> <td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
<td class="text-end">@entry.Total.ToString("C")</td> <td class="text-end">@entry.Total.ToString("C")</td>
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")"> <td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;") @(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td> </td>
<td> <td>
@if (entry.EntryType == "Bill") @if (entry.EntryType == "Bill")
@@ -148,7 +148,7 @@
<div class="card-body"> <div class="card-body">
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr> <tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr>
<tr><th>Code</th><td>@Html.Raw(Model.CompanyCode ?? "&mdash;")</td></tr> <tr><th>Code</th><td>@(Model.CompanyCode ?? "&mdash;")</td></tr>
<tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr> <tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr>
<tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr> <tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr>
<tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr> <tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr>
@@ -174,7 +174,7 @@
<table class="table table-sm table-borderless mb-0"> <table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr> <tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr>
<tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr> <tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr>
<tr><th>Phone</th><td>@Html.Raw(Model.Phone ?? "&mdash;")</td></tr> <tr><th>Phone</th><td>@(Model.Phone ?? "&mdash;")</td></tr>
</table> </table>
</div> </div>
</div> </div>
@@ -283,7 +283,7 @@
} }
else { <span class="text-muted">N/A</span> } else { <span class="text-muted">N/A</span> }
</td> </td>
<td>@Html.Raw(user.Department ?? "&mdash;")</td> <td>@(user.Department ?? "&mdash;")</td>
<td> <td>
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")"> <span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
@(user.IsActive ? "Active" : "Inactive") @(user.IsActive ? "Active" : "Inactive")
@@ -527,20 +527,20 @@
@{ @{
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt; var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
} }
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;") @(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
</td> </td>
</tr> </tr>
<tr> <tr>
<th>First Invoice</th> <th>First Invoice</th>
<td>@Html.Raw(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td> <td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr> </tr>
<tr> <tr>
<th>Workflow Completed</th> <th>Workflow Completed</th>
<td>@Html.Raw(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td> <td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr> </tr>
<tr> <tr>
<th>Widget Dismissed</th> <th>Widget Dismissed</th>
<td>@Html.Raw(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td> <td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -106,11 +106,6 @@
<i class="bi bi-tablet"></i> Kiosk <i class="bi bi-tablet"></i> Kiosk
</button> </button>
</li> </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> </ul>
<!-- Tabs Content --> <!-- Tabs Content -->
@@ -2059,179 +2054,6 @@
</div> </div>
</div> </div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-calculator me-2"></i>Custom Formula Item Templates</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
<i class="bi bi-question-circle me-1"></i>How it works
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Define reusable pricing formulas for complex fabricated items (roof curbs, enclosures, frames).
When a user adds a formula item to a quote or job, they fill in the measurements and the formula
calculates the price automatically.
</p>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="cfTemplatesTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Output Mode</th>
<th>Fields</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody id="cfTemplatesBody">
<tr><td colspan="5" class="text-muted text-center py-3">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Formula Walkthrough Modal -->
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="cfWalkthroughLabel">
<i class="bi bi-calculator text-info me-2"></i>Custom Formula Templates &mdash; How It Works
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-2">
<!-- Step progress dots -->
<div class="d-flex justify-content-center gap-2 mb-4" id="cfWalkthroughDots"></div>
<!-- Step content -->
<div id="cfWalkthroughContent"></div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" id="cfWtPrevBtn" onclick="cfWalkthroughNav(-1)">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
<button type="button" class="btn btn-primary" id="cfWtNextBtn" onclick="cfWalkthroughNav(1)">
Next<i class="bi bi-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Custom Formula Template Modal -->
<div class="modal fade" id="cfModal" tabindex="-1" aria-labelledby="cfModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfModalLabel">New Formula Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cfId" value="0" />
<div class="row g-3">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" id="cfName" class="form-control" placeholder="e.g. Roof Curb" maxlength="100" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="cfDescription" class="form-control" maxlength="500" />
</div>
<div class="mb-3">
<label class="form-label">Output Mode <span class="text-danger">*</span></label>
<select id="cfOutputMode" class="form-select" onchange="cfToggleRateFields()">
<option value="FixedRate">Fixed Rate &mdash; formula &rarr; $ amount</option>
<option value="SurfaceAreaSqFt">Surface Area &mdash; formula &rarr; sq ft (standard pricing engine prices it)</option>
</select>
</div>
<div id="cfRateFields">
<div class="mb-3">
<label class="form-label">Default Rate</label>
<input type="number" id="cfDefaultRate" class="form-control" step="0.01" placeholder="e.g. 0.85" />
<div class="form-text">Used as the <code>rate</code> variable if not overridden per-quote.</div>
</div>
<div class="mb-3">
<label class="form-label">Rate Label</label>
<input type="text" id="cfRateLabel" class="form-control" maxlength="50" placeholder="e.g. $/sq ft" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Formula <span class="text-danger">*</span></label>
<input type="text" id="cfFormula" class="form-control font-monospace" placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate" />
<div class="form-text mt-1">
<span class="me-1">Available variables for this formula:</span>
<span id="cfVariablePills"></span>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea id="cfNotes" class="form-control" rows="2" maxlength="1000"></textarea>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="cfIsActive" class="form-check-input" checked />
<label class="form-check-label" for="cfIsActive">Active (show in quote/job wizard)</label>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Fields</label>
<div class="form-text mb-2">Define the measurement inputs users will fill in.</div>
<div id="cfFieldsList"></div>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="cfAddField()">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
<div class="mb-3">
<label class="form-label">Formula Test</label>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="cfTestFormula()">
<i class="bi bi-play-circle"></i> Run
</button>
<span id="cfTestResult" class="fw-bold"></span>
</div>
<div class="form-text">Uses the default values from your field list.</div>
</div>
<div class="mb-3">
<label class="form-label">Diagram / Shop Drawing</label>
<div id="cfDiagramPreview" class="mb-2" style="display:none;">
<img id="cfDiagramImg" src="" alt="Diagram" class="img-fluid rounded border" style="max-height:180px;" />
</div>
<input type="file" id="cfDiagramFile" class="form-control form-control-sm" accept="image/*" onchange="cfPreviewDiagram(event)" />
<div class="form-text">Optional. Upload a shop drawing or photo to help users recognize this item.</div>
</div>
<div class="mb-3">
<label class="form-label">AI Formula Generator</label>
<div class="input-group">
<input type="text" id="cfAiPrompt" class="form-control" placeholder="Describe the item, e.g. 'Rectangular roof curb with flanged base'" />
<button type="button" class="btn btn-outline-secondary" onclick="cfGenerateFromAi()" id="cfAiBtn">
<i class="bi bi-stars"></i> Generate
</button>
</div>
<div class="form-text">Claude will suggest a formula, fields, and mode. You can edit before saving.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="cfSave()">
<i class="bi bi-floppy me-1"></i>Save Template
</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -3467,24 +3289,6 @@
const btn = document.querySelector('[data-bs-target="#kiosk"]'); const btn = document.querySelector('[data-bs-target="#kiosk"]');
if (btn) new bootstrap.Tab(btn).show(); if (btn) new bootstrap.Tab(btn).show();
} }
if (urlParams.get('tab') === 'custom-formulas') {
const btn = document.querySelector('[data-bs-target="#custom-formulas"]');
if (btn) new bootstrap.Tab(btn).show();
}
</script>
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script>
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
document.querySelector('[data-bs-target="#custom-formulas"]').addEventListener('shown.bs.tab', async () => {
if (!window._cfLoaded) {
await cfLoadTemplates();
window._cfLoaded = true;
if (!localStorage.getItem('cfWalkthroughSeen')) {
const hasTemplates = document.querySelectorAll('#cfTemplatesBody tr[data-id]').length > 0;
if (!hasTemplates) cfShowWalkthrough();
}
}
});
</script> </script>
} }
@@ -181,7 +181,7 @@
</td> </td>
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td> <td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td> <td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
<td class="small text-muted">@Html.Raw(a.AppliedBy?.FullName ?? "&mdash;")</td> <td class="small text-muted">@(a.AppliedBy?.FullName ?? "&mdash;")</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -204,7 +204,7 @@ else
</td> </td>
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td> <td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="@(expired ? "text-danger fw-semibold" : "")"> <td class="@(expired ? "text-danger fw-semibold" : "")">
@Html.Raw(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "&mdash;") @(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "&mdash;")
@if (expired) { <small>(Expired)</small> } @if (expired) { <small>(Expired)</small> }
</td> </td>
<td> <td>
@@ -224,7 +224,7 @@
} }
<div class="mobile-card-row"> <div class="mobile-card-row">
<span class="mobile-card-label">Phone</span> <span class="mobile-card-label">Phone</span>
<span class="mobile-card-value">@Html.Raw(customer.Phone ?? "&mdash;")</span> <span class="mobile-card-value">@(customer.Phone ?? "&mdash;")</span>
</div> </div>
<div class="mobile-card-row"> <div class="mobile-card-row">
<span class="mobile-card-label">Type</span> <span class="mobile-card-label">Type</span>
@@ -563,7 +563,7 @@
<tr> <tr>
<td colspan="2">Vendor Total</td> <td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td> <td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td> <td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td></td> <td></td>
</tr> </tr>
</tfoot> </tfoot>
@@ -680,7 +680,7 @@
<tr> <tr>
<td colspan="2">Vendor Total</td> <td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td> <td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td> <td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td colspan="2"></td> <td colspan="2"></td>
</tr> </tr>
</tfoot> </tfoot>
@@ -139,7 +139,7 @@
else { <span class="text-muted">&mdash;</span> } else { <span class="text-muted">&mdash;</span> }
</td> </td>
<td class="text-muted"> <td class="text-muted">
@Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;") @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")
</td> </td>
<td class="text-center"> <td class="text-center">
<input type="checkbox" class="form-check-input entity-select" <input type="checkbox" class="form-check-input entity-select"
@@ -169,7 +169,7 @@
</div> </div>
<div class="mobile-card-title"> <div class="mobile-card-title">
<h6>@s.Label</h6> <h6>@s.Label</h6>
<small>Oldest: @Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")</small> <small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")</small>
</div> </div>
</div> </div>
<div class="mobile-card-body"> <div class="mobile-card-body">
@@ -78,7 +78,7 @@
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div> <div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
</td> </td>
<td> <td>
<div>@Html.Raw(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "&mdash;" : row.CompanyAdminName)</div> <div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "&mdash;" : row.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail)) @if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
{ {
<div class="small text-muted">@row.CompanyAdminEmail</div> <div class="small text-muted">@row.CompanyAdminEmail</div>
@@ -75,7 +75,7 @@
<div class="fw-semibold">@company.CompanyName</div> <div class="fw-semibold">@company.CompanyName</div>
<div class="small text-muted">#@company.CompanyId</div> <div class="small text-muted">#@company.CompanyId</div>
</td> </td>
<td>@Html.Raw(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "&mdash;" : company.PrimaryContactName)</td> <td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "&mdash;" : company.PrimaryContactName)</td>
<td> <td>
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail)) @if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
{ {
@@ -87,7 +87,7 @@
} }
</td> </td>
<td> <td>
<div>@Html.Raw(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "&mdash;" : company.CompanyAdminName)</div> <div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "&mdash;" : company.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail)) @if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
{ {
<div class="small text-muted">@company.CompanyAdminEmail</div> <div class="small text-muted">@company.CompanyAdminEmail</div>
@@ -42,7 +42,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="text-muted small mb-1">Location</label> <label class="text-muted small mb-1">Location</label>
<p class="mb-0">@Html.Raw(Model.Location ?? "&mdash;")</p> <p class="mb-0">@(Model.Location ?? "&mdash;")</p>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="text-muted small mb-1">Status</label> <label class="text-muted small mb-1">Status</label>
@@ -76,15 +76,15 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label> <label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "&mdash;")</p> <p class="mb-0">@(Model.Manufacturer ?? "&mdash;")</p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Model</label> <label class="text-muted small mb-1">Model</label>
<p class="mb-0">@Html.Raw(Model.Model ?? "&mdash;")</p> <p class="mb-0">@(Model.Model ?? "&mdash;")</p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label> <label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "&mdash;")</p> <p class="mb-0">@(Model.SerialNumber ?? "&mdash;")</p>
</div> </div>
</div> </div>
</div> </div>
@@ -94,15 +94,15 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label> <label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "&mdash;")</p> <p class="mb-0">@(Model.Manufacturer ?? "&mdash;")</p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Model</label> <label class="text-muted small mb-1">Model</label>
<p class="mb-0">@Html.Raw(Model.Model ?? "&mdash;")</p> <p class="mb-0">@(Model.Model ?? "&mdash;")</p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label> <label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "&mdash;")</p> <p class="mb-0">@(Model.SerialNumber ?? "&mdash;")</p>
</div> </div>
</div> </div>
</div> </div>
@@ -88,7 +88,7 @@
<td>@cert.OriginalAmount.ToString("C")</td> <td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td> <td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td> <td>
@Html.Raw(cert.ExpiryDate.HasValue @(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") ? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;") : "&mdash;")
</td> </td>
@@ -1,161 +0,0 @@
@{
ViewData["Title"] = "Custom Formula Item Templates &mdash; Help";
}
<div class="row g-4">
<div class="col-lg-9">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help Center</a></li>
<li class="breadcrumb-item active">Custom Formula Item Templates</li>
</ol>
</nav>
<h1 class="h3 mb-1"><i class="bi bi-calculator text-info me-2"></i>Custom Formula Item Templates</h1>
<p class="text-muted mb-4">Build reusable pricing formulas for complex fabricated items.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">What are formula templates?</h2>
<p>
Some items &mdash; roof curbs, electrical enclosures, welded frames &mdash; have prices that depend on
exact measurements rather than estimated surface area. Custom Formula Item Templates let you define a
reusable NCalc expression that automatically calculates the price (or surface area) once a user enters
the measurements.
</p>
<p>
Templates are created once in <strong>Company Settings &rarr; Custom Formulas</strong> and then
appear as a selectable item type in the quote and job item wizards.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Output modes</h2>
<div class="table-responsive">
<table class="table table-sm">
<thead class="table-light">
<tr><th>Mode</th><th>Formula output</th><th>How it&rsquo;s priced</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Fixed Rate</strong></td>
<td>A dollar amount</td>
<td>Stored as <code>ManualUnitPrice</code>; multiplied by quantity for the line total.</td>
</tr>
<tr>
<td><strong>Surface Area</strong></td>
<td>Square footage</td>
<td>Passed to the standard coating engine; priced per your operating cost rates.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Creating a template</h2>
<ol>
<li>Go to <strong>Company Settings &rarr; Custom Formulas</strong> and click <strong>New Template</strong>.</li>
<li>Enter a name and choose the output mode.</li>
<li>Add the measurement <strong>fields</strong> users will fill in (e.g. <code>length_in</code>, <code>width_in</code>).</li>
<li>Write the <strong>formula</strong> using those field names. Example for a box surface area in inches:
<pre class="bg-light p-2 rounded mt-1 mb-0">2*(length_in*width_in + length_in*height_in + width_in*height_in) / 144 * rate</pre>
</li>
<li>Click <strong>Run</strong> to test the formula with your default values.</li>
<li>Optionally upload a <strong>diagram image</strong> &mdash; users will see it when they select this template.</li>
<li>Save the template.</li>
</ol>
<h3 class="h6 mt-3">Using AI to generate a formula</h3>
<p>
In the template editor, enter a description of the item in the <strong>AI Formula Generator</strong> box
and optionally attach a diagram image. Claude will suggest a formula, field list, and output mode.
Review and adjust before saving.
</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Adding a formula item to a quote or job</h2>
<ol>
<li>In the item wizard, select <strong>Custom Formula Item</strong> (only visible if at least one active template exists).</li>
<li>Choose a template from the dropdown. The template&rsquo;s diagram will appear for reference.</li>
<li>Enter the measurements and click <strong>Calculate</strong> to preview the result.</li>
<li>Adjust the description and quantity, then continue to the coatings and prep steps.</li>
</ol>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">Common formula patterns</h2>
<p>These ready-to-use templates cover the most common fabricated item shapes. Copy the fields and formula exactly, then adjust the Default Rate for your shop.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-box me-1"></i>6-Sided Box (roof curbs, enclosures)</h3>
<p class="text-muted small mb-2">Calculates the total outer surface area of all six faces.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>l_in</code></td><td>Length (inches)</td><td>24</td></tr>
<tr><td><code>w_in</code></td><td>Width (inches)</td><td>24</td></tr>
<tr><td><code>h_in</code></td><td>Height (inches)</td><td>12</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate</pre>
<p class="text-muted small mb-3">Output mode: <strong>Fixed Rate</strong> &mdash; suggested rate: your $/sqft coating price. A 24&times;24&times;12 box at $3.50/sqft = $28.00.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-circle me-1"></i>Cylinder (pipe ends, round housings)</h3>
<p class="text-muted small mb-2">Lateral surface plus two circular end caps.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>d_in</code></td><td>Diameter (inches)</td><td>12</td></tr>
<tr><td><code>h_in</code></td><td>Height (inches)</td><td>18</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">(3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate</pre>
<p class="text-muted small mb-3">Output mode: <strong>Fixed Rate</strong> &mdash; a 12&Prime; diameter &times; 18&Prime; tall cylinder at $3.50/sqft &asymp; $10.21.</p>
<h3 class="h6 mt-3 mb-2"><i class="bi bi-layout-text-window me-1"></i>Flat Panel</h3>
<p class="text-muted small mb-2">Simple rectangular sheet &mdash; one face only.</p>
<div class="table-responsive mb-2">
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Variable name</th><th>Label</th><th>Default</th></tr></thead>
<tbody>
<tr><td><code>l_in</code></td><td>Length (inches)</td><td>24</td></tr>
<tr><td><code>w_in</code></td><td>Width (inches)</td><td>12</td></tr>
</tbody>
</table>
</div>
<pre class="bg-light p-2 rounded mb-1">l_in * w_in / 144 * rate</pre>
<p class="text-muted small mb-0">Output mode: <strong>Fixed Rate</strong> &mdash; a 24&Prime;&times;12&Prime; panel at $3.50/sqft = $7.00.</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h5">NCalc formula reference</h2>
<p>Formulas use <a href="https://ncalc.github.io/ncalc/" target="_blank" rel="noopener">NCalc</a> syntax:</p>
<ul>
<li>Standard operators: <code>+ - * / % Pow(b, e)</code></li>
<li>Functions: <code>Abs(x) Round(x, d) Max(a, b) Min(a, b) Sqrt(x)</code></li>
<li>Variable names must start with a letter and contain only letters, digits, or underscores.</li>
<li>The reserved variable <code>rate</code> is pre-populated from the template&rsquo;s Default Rate.</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-3 d-none d-lg-block">
@await Html.PartialAsync("_HelpNav")
</div>
</div>
@@ -258,22 +258,6 @@
</div> </div>
</div> </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>
</div> </div>
@@ -347,14 +347,10 @@
</p> </p>
<h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3> <h3 class="h6 fw-semibold mt-4 mb-2">Printing a label</h3>
<p>You can print from two places &mdash; the item's Details page, or directly from the list without opening the item.</p>
<ol class="mb-3"> <ol class="mb-3">
<li class="mb-1"> <li class="mb-1">Open the inventory item's Details page.</li>
<strong>From the list:</strong> click the <i class="bi bi-qr-code"></i> QR icon (first button in the Actions column) next to any row. <li class="mb-1">Click <strong>Print QR Label</strong> in the Actions panel &mdash; the label opens in a new tab.</li>
<br /><strong>From Details:</strong> click <strong>Print QR Label</strong> in the Actions panel. <li class="mb-1">Click <strong>Print Label</strong> and send it to your printer. The label is sized for a standard 3.5&Prime; label and includes the item name, SKU, colour, finish, and manufacturer.</li>
</li>
<li class="mb-1">A preview modal opens showing the label &mdash; item name, SKU, colour, finish, and QR code.</li>
<li class="mb-1">Click <strong>Print Label</strong> inside the modal to send it to your printer. No new tab is opened; printing happens directly in the modal. The label is sized for a standard 3.5&Prime; label.</li>
</ol> </ol>
<h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3> <h3 class="h6 fw-semibold mt-4 mb-2">Scanning and logging usage</h3>
+5 -34
View File
@@ -475,41 +475,12 @@
actual hours vs. estimated hours for costing and productivity analysis. actual hours vs. estimated hours for costing and productivity analysis.
</p> </p>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework (also called Redo)</h3> <h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
<p> <p>
If a finished part fails quality inspection or a customer returns it damaged, open the original If finished parts fail quality inspection or need to be re-coated, create a rework record
job&rsquo;s Details page and use the <strong>Rework Log</strong> section to record it. Rework from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
and redo mean the same thing throughout the system. color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
</p> issues over time.
<p>Each entry captures the type (internal defect, customer damage, warranty), the reason (adhesion
failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who
discovered the issue, and pricing responsibility.</p>
<h4 class="h6 fw-semibold mt-3 mb-1">Pricing Responsibility</h4>
<ul>
<li><strong>Shop Fault &mdash; no charge:</strong> All copied item prices are set to $0.</li>
<li><strong>Customer responsible &mdash; reduced rate:</strong> Prices are copied from the original job; edit them down after creation.</li>
<li><strong>Customer responsible &mdash; full price:</strong> Prices are copied as-is.</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Creating a Rework Job</h4>
<p>
Toggle <strong>Parts are back &mdash; create a Rework Job</strong> at the top of the log form.
Select the items that need to be redone and choose the pricing responsibility. The system will:
</p>
<ul>
<li>Create a new job with a sub-number (e.g., <code>JOB-2605-0001-R1</code>)</li>
<li>Copy the selected items with their coats and prep services</li>
<li>Auto-record intake &mdash; parts are already on hand when rework is logged</li>
<li>Set the job description to the defect type, reason, and pricing so it is visible at the top of the job</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Automatic Resolution</h4>
<p>
When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework
record on the original job is automatically marked <strong>Resolved</strong> &mdash; no manual
follow-up needed. If the rework job is <strong>Cancelled</strong>, the record is marked
<strong>Written Off</strong> instead.
</p> </p>
</section> </section>
@@ -149,43 +149,6 @@
</div> </div>
</section> </section>
<section id="supply-categories" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tags text-primary me-2"></i>Supply Categories
</h2>
<p>
Each vendor can be tagged with one or more <strong>Supply Categories</strong> &mdash; for example,
<em>Powder</em>, <em>Chemical</em>, or <em>Consumables</em>. These tags tell the system what types of
inventory items this vendor supplies.
</p>
<p>
When you add or edit an inventory item and choose a <strong>Category</strong>, the vendor dropdown
automatically filters to show only vendors tagged for that category. This prevents accidentally
selecting an unrelated supplier.
</p>
<ul class="mb-3">
<li>A vendor can belong to <strong>multiple categories</strong> &mdash; check as many boxes as apply.</li>
<li>If <strong>no vendor</strong> in your list is tagged for the selected category, the dropdown falls back to
showing all active vendors so you are never blocked.</li>
<li>A <em>&ldquo;Showing vendors for this category&rdquo;</em> note appears below the dropdown when the filter is
active. Click <strong>Show all</strong> in that note to temporarily override the filter.</li>
</ul>
<p>To tag a vendor with supply categories:</p>
<ol class="mb-3">
<li class="mb-1">Open the vendor's Edit page (click the vendor name in the list, then <strong>Edit</strong>).</li>
<li class="mb-1">In the <strong>Supply Categories</strong> section, check the boxes that apply.</li>
<li class="mb-1">Click <strong>Save Vendor</strong>.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Supply categories are the same categories used on your inventory items. If you add a new category
under <strong>Settings &rsaquo; Inventory Categories</strong>, it will appear automatically in the vendor
checkboxes.
</div>
</div>
</section>
<section id="deactivating-a-vendor" class="mb-5"> <section id="deactivating-a-vendor" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3"> <h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-truck text-primary me-2" style="text-decoration:line-through"></i>Deactivating a Vendor <i class="bi bi-truck text-primary me-2" style="text-decoration:line-through"></i>Deactivating a Vendor
@@ -223,7 +186,6 @@
<a class="nav-link py-1 px-3 small text-body" href="#default-expense-account">Default Expense Account</a> <a class="nav-link py-1 px-3 small text-body" href="#default-expense-account">Default Expense Account</a>
<a class="nav-link py-1 px-3 small text-body" href="#payment-terms">Payment Terms</a> <a class="nav-link py-1 px-3 small text-body" href="#payment-terms">Payment Terms</a>
<a class="nav-link py-1 px-3 small text-body" href="#preferred-vendor">Preferred Vendor</a> <a class="nav-link py-1 px-3 small text-body" href="#preferred-vendor">Preferred Vendor</a>
<a class="nav-link py-1 px-3 small text-body" href="#supply-categories">Supply Categories</a>
<a class="nav-link py-1 px-3 small text-body" href="#vendor-details">Vendor Details Page</a> <a class="nav-link py-1 px-3 small text-body" href="#vendor-details">Vendor Details Page</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-vendor">Deactivating a Vendor</a> <a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-vendor">Deactivating a Vendor</a>
</nav> </nav>
@@ -345,10 +345,6 @@
<option value="">Select vendor</option> <option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor&hellip;</option> <option value="__new__">+ Add New Vendor&hellip;</option>
</select> </select>
<div id="vendor-filter-note" class="form-text d-none">
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
</div>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span> <span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div> </div>
</div> </div>
@@ -442,38 +438,4 @@
{ {
<script src="~/js/inventory-label-scan.js"></script> <script src="~/js/inventory-label-scan.js"></script>
} }
<script>
(function () {
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
const vendorSelect = document.getElementById('field-vendor');
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
function filterVendors(catId, forceAll) {
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
const isFiltered = vendorIds.length > 0;
const currentVal = vendorSelect.value;
vendorSelect.innerHTML = '';
allVendorOptions.forEach(function (opt) {
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
vendorSelect.add(new Option(opt.t, opt.v));
});
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
vendorSelect.value = currentVal;
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
}
document.getElementById('field-category').addEventListener('change', function () {
filterVendors(this.value, false);
});
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
e.preventDefault();
filterVendors(document.getElementById('field-category').value, true);
});
filterVendors(document.getElementById('field-category').value, false);
})();
</script>
} }
@@ -5,11 +5,6 @@
ViewData["PageIcon"] = "bi-box-seam"; ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Inventory Item"; ViewData["PageHelpTitle"] = "Inventory Item";
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds &mdash; a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item."; ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds &mdash; a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
string SafeUrl(string? url) =>
string.IsNullOrEmpty(url) ? "#"
: (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
? url : "http://" + url;
} }
@section Styles { @section Styles {
@@ -189,7 +184,7 @@
<div class="col-12"> <div class="col-12">
<label class="text-muted small mb-1">Product URL</label> <label class="text-muted small mb-1">Product URL</label>
<p class="mb-0"> <p class="mb-0">
<a href="@SafeUrl(Model.SpecPageUrl)" target="_blank" class="text-decoration-none"> <a href="@Model.SpecPageUrl" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site <i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
</a> </a>
</p> </p>
@@ -202,13 +197,13 @@
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl)) @if (!string.IsNullOrEmpty(Model.SdsUrl))
{ {
<a href="@SafeUrl(Model.SdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary"> <a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet <i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a> </a>
} }
@if (!string.IsNullOrEmpty(Model.TdsUrl)) @if (!string.IsNullOrEmpty(Model.TdsUrl))
{ {
<a href="@SafeUrl(Model.TdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary"> <a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet <i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</a> </a>
} }
@@ -298,14 +293,14 @@
{ {
<div class="col-md-6"> <div class="col-md-6">
<label class="text-muted small mb-1">Inventory Account</label> <label class="text-muted small mb-1">Inventory Account</label>
<p class="mb-0">@Html.Raw(Model.InventoryAccountName ?? "&mdash;")</p> <p class="mb-0">@(Model.InventoryAccountName ?? "&mdash;")</p>
</div> </div>
} }
@if (Model.CogsAccountId.HasValue) @if (Model.CogsAccountId.HasValue)
{ {
<div class="col-md-6"> <div class="col-md-6">
<label class="text-muted small mb-1">COGS Account</label> <label class="text-muted small mb-1">COGS Account</label>
<p class="mb-0">@Html.Raw(Model.CogsAccountName ?? "&mdash;")</p> <p class="mb-0">@(Model.CogsAccountName ?? "&mdash;")</p>
</div> </div>
} }
</div> </div>
@@ -380,7 +375,7 @@
</div> </div>
<div class="col-6"> <div class="col-6">
<label class="text-muted small mb-1">Location</label> <label class="text-muted small mb-1">Location</label>
<p class="mb-0">@Html.Raw(Model.Location ?? "&mdash;")</p> <p class="mb-0">@(Model.Location ?? "&mdash;")</p>
</div> </div>
<div class="col-6"> <div class="col-6">
<label class="text-muted small mb-1">Reorder Point</label> <label class="text-muted small mb-1">Reorder Point</label>
@@ -457,9 +452,9 @@
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal"> <button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal">
<i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment <i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment
</button> </button>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#qrLabelModal"> <a asp-action="Label" asp-route-id="@Model.Id" target="_blank" class="btn btn-outline-secondary">
<i class="bi bi-qr-code me-2"></i>Print QR Label <i class="bi bi-qr-code me-2"></i>Print QR Label
</button> </a>
<a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary"> <a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Activity History <i class="bi bi-journal-text me-2"></i>View Activity History
</a> </a>
@@ -644,33 +639,6 @@
</div> </div>
</div> </div>
@* QR Label Modal *@
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="qrLabelModalLabel">
<i class="bi bi-qr-code me-2"></i>QR Label &mdash; @Model.Name
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
<iframe id="qrLabelFrame"
src="@Url.Action("Label", new { id = Model.Id, embed = true })"
style="width:100%;height:400px;border:none;"
title="QR Label Preview"></iframe>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-primary btn-sm"
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
<i class="bi bi-printer me-2"></i>Print Label
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts { @section Scripts {
<script> <script>
/* ── Stock Adjustment Modal ───────────────────────────────── */ /* ── Stock Adjustment Modal ───────────────────────────────── */
@@ -341,10 +341,6 @@
<option value="">Select vendor</option> <option value="">Select vendor</option>
<option value="__new__">+ Add New Vendor&hellip;</option> <option value="__new__">+ Add New Vendor&hellip;</option>
</select> </select>
<div id="vendor-filter-note" class="form-text d-none">
<i class="bi bi-funnel me-1 text-info"></i><span class="text-info">Showing vendors for this category.</span>
<a href="#" id="vendor-filter-clear" class="ms-1">Show all</a>
</div>
<span asp-validation-for="PrimaryVendorId" class="text-danger"></span> <span asp-validation-for="PrimaryVendorId" class="text-danger"></span>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -461,39 +457,4 @@
{ {
<script src="~/js/inventory-label-scan.js"></script> <script src="~/js/inventory-label-scan.js"></script>
} }
<script>
(function () {
const categoryVendorMap = @Html.Raw(ViewBag.CategoryVendorMapJson ?? "{}");
const vendorSelect = document.getElementById('field-vendor');
const allVendorOptions = Array.from(vendorSelect.options).map(o => ({ v: o.value, t: o.text }));
function filterVendors(catId, forceAll) {
const vendorIds = (!forceAll && catId) ? (categoryVendorMap[catId] || []) : [];
const isFiltered = vendorIds.length > 0;
const currentVal = vendorSelect.value;
vendorSelect.innerHTML = '';
allVendorOptions.forEach(function (opt) {
if (!isFiltered || !opt.v || opt.v === '__new__' || vendorIds.includes(Number(opt.v)))
vendorSelect.add(new Option(opt.t, opt.v));
});
if (Array.from(vendorSelect.options).some(o => o.value === currentVal))
vendorSelect.value = currentVal;
document.getElementById('vendor-filter-note').classList.toggle('d-none', !isFiltered);
}
document.getElementById('field-category').addEventListener('change', function () {
filterVendors(this.value, false);
});
document.getElementById('vendor-filter-clear')?.addEventListener('click', function (e) {
e.preventDefault();
filterVendors(document.getElementById('field-category').value, true);
});
// Apply on load — Edit already has a category selected
filterVendors(document.getElementById('field-category').value, false);
})();
</script>
} }
@@ -340,11 +340,6 @@
</td> </td>
<td class="text-end pe-4"> <td class="text-end pe-4">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary"
title="Print QR Label"
onclick="openQrLabelModal(@item.Id, event)">
<i class="bi bi-qr-code"></i>
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details"> <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
@@ -459,10 +454,6 @@
</div> </div>
<div class="mobile-card-footer"> <div class="mobile-card-footer">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="openQrLabelModal(@item.Id, event)">
<i class="bi bi-qr-code me-1"></i>QR Label
</button>
<a href="@Url.Action("Details", new { id = item.Id })" <a href="@Url.Action("Details", new { id = item.Id })"
class="btn btn-sm btn-outline-primary" class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();"> onclick="event.stopPropagation();">
@@ -486,42 +477,8 @@
} }
</div> </div>
@* QR Label Modal (shared across all items — src set dynamically by JS) *@
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="qrLabelModalLabel">
<i class="bi bi-qr-code me-2"></i>QR Label
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
<iframe id="qrLabelFrame"
src="about:blank"
style="width:100%;height:400px;border:none;"
title="QR Label Preview"></iframe>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-primary btn-sm"
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
<i class="bi bi-printer me-2"></i>Print Label
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts { @section Scripts {
<script> <script>
function openQrLabelModal(itemId, e) {
e.stopPropagation();
const frame = document.getElementById('qrLabelFrame');
frame.src = '@Url.Action("Label", "Inventory")/' + itemId + '?embed=true';
bootstrap.Modal.getOrCreateInstance(document.getElementById('qrLabelModal')).show();
}
// Make table rows clickable // Make table rows clickable
document.querySelectorAll('.inventory-row').forEach(row => { document.querySelectorAll('.inventory-row').forEach(row => {
row.addEventListener('click', function(e) { row.addEventListener('click', function(e) {
@@ -22,11 +22,6 @@
min-height: 100vh; min-height: 100vh;
} }
body.embedded {
padding-top: 24px;
min-height: auto;
}
.screen-controls { .screen-controls {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -117,10 +112,8 @@
} }
</style> </style>
</head> </head>
<body class="@((bool)(ViewBag.IsEmbed ?? false) ? "embedded" : "")"> <body>
@if (!(bool)(ViewBag.IsEmbed ?? false))
{
<div class="screen-controls"> <div class="screen-controls">
<button class="btn btn-primary" onclick="window.print()"> <button class="btn btn-primary" onclick="window.print()">
&#128438; Print Label &#128438; Print Label
@@ -129,7 +122,6 @@
&#8592; Back to Item &#8592; Back to Item
</a> </a>
</div> </div>
}
<div class="label-card"> <div class="label-card">
<div class="label-logo">Powder Coating Logix</div> <div class="label-logo">Powder Coating Logix</div>
@@ -291,7 +291,7 @@
} }
</td> </td>
} }
<td>@Html.Raw(u.CoatColor ?? "&mdash;")</td> <td>@(u.CoatColor ?? "&mdash;")</td>
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td> <td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td> <td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")"> <td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
@@ -81,7 +81,7 @@ else
<tr> <tr>
<td style="color:#aaa;font-size:9pt;">@row</td> <td style="color:#aaa;font-size:9pt;">@row</td>
<td><strong>@item.Name</strong></td> <td><strong>@item.Name</strong></td>
<td>@Html.Raw(item.ColorName ?? "&mdash;")</td> <td>@(item.ColorName ?? "&mdash;")</td>
<td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td> <td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td>
</tr> </tr>
} }
@@ -244,9 +244,9 @@
<div class="text-muted small">@item.Name</div> <div class="text-muted small">@item.Name</div>
} }
</td> </td>
<td>@Html.Raw(item.Manufacturer ?? "&mdash;")</td> <td>@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "&mdash;")</td> <td class="text-muted small">@(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@Html.Raw(item.Finish ?? "&mdash;")</td> <td>@(item.Finish ?? "&mdash;")</td>
<td> <td>
@if (item.QuantityOnHand > 0) @if (item.QuantityOnHand > 0)
{ {
@@ -399,9 +399,9 @@
<div class="text-muted small">@item.Name</div> <div class="text-muted small">@item.Name</div>
} }
</td> </td>
<td>@Html.Raw(item.Manufacturer ?? "&mdash;")</td> <td>@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "&mdash;")</td> <td class="text-muted small">@(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@Html.Raw(item.Finish ?? "&mdash;")</td> <td>@(item.Finish ?? "&mdash;")</td>
<td class="text-end pe-3"> <td class="text-end pe-3">
<button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel" <button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="false" data-item-id="@item.Id" data-has-panel="false"
@@ -461,7 +461,7 @@
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td> <td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td> <td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td> <td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@Html.Raw(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "&mdash;")</td> <td style="border:1px solid #ccc;padding:6px 10px;">@(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "&mdash;")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">&nbsp;</td> <td style="border:1px solid #ccc;padding:6px 10px;">&nbsp;</td>
</tr> </tr>
} }
@@ -179,12 +179,12 @@
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Due Date</label> <label class="text-muted small mb-1">Due Date</label>
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")"> <p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
@Html.Raw(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "&mdash;") @(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "&mdash;")
</p> </p>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="text-muted small mb-1">Sent Date</label> <label class="text-muted small mb-1">Sent Date</label>
<p class="mb-0">@Html.Raw(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "&mdash;")</p> <p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "&mdash;")</p>
</div> </div>
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO)) @if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
{ {
@@ -350,7 +350,7 @@
</span> </span>
</td> </td>
<td class="text-muted"> <td class="text-muted">
@Html.Raw(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "&mdash;") @(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "&mdash;")
</td> </td>
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td> <td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
<td> <td>
@@ -396,7 +396,7 @@
<tr> <tr>
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td> <td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
<td>@p.PaymentMethodDisplay</td> <td>@p.PaymentMethodDisplay</td>
<td>@Html.Raw(p.Reference ?? "&mdash;")</td> <td>@(p.Reference ?? "&mdash;")</td>
<td> <td>
@if (!string.IsNullOrEmpty(p.DepositAccountName)) @if (!string.IsNullOrEmpty(p.DepositAccountName))
{ {
@@ -407,7 +407,7 @@
<span class="text-muted">&mdash;</span> <span class="text-muted">&mdash;</span>
} }
</td> </td>
<td>@Html.Raw(p.RecordedByName ?? "&mdash;")</td> <td>@(p.RecordedByName ?? "&mdash;")</td>
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td> <td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
<td class="text-end"> <td class="text-end">
@if (!isVoided) @if (!isVoided)
@@ -463,7 +463,7 @@
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td> <td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
<td>@r.RefundMethodDisplay</td> <td>@r.RefundMethodDisplay</td>
<td>@r.Reason</td> <td>@r.Reason</td>
<td>@Html.Raw(r.Reference ?? "&mdash;")</td> <td>@(r.Reference ?? "&mdash;")</td>
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td> <td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td> <td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
<td class="text-nowrap"> <td class="text-nowrap">
@@ -144,7 +144,7 @@
</td> </td>
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td> <td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")"> <td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
@Html.Raw(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "&mdash;") @(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "&mdash;")
</td> </td>
<td class="text-end">@inv.Total.ToString("C")</td> <td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")"> <td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@@ -128,7 +128,7 @@
<td class="text-end">@gross.ToString("C")</td> <td class="text-end">@gross.ToString("C")</td>
<td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td> <td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td>
<td class="text-end fw-semibold">@net.ToString("C")</td> <td class="text-end fw-semibold">@net.ToString("C")</td>
<td>@Html.Raw(dateDisplay)</td> <td>@dateDisplay</td>
<td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td> <td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td>
<td> <td>
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId)) @if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
@@ -206,7 +206,7 @@
} }
else else
{ {
@Html.Raw(invNum) @invNum
} }
</td> </td>
<td>@custName</td> <td>@custName</td>
@@ -397,11 +397,6 @@
complexity = item.Complexity, complexity = item.Complexity,
isGenericItem = item.IsGenericItem, isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem, isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting, requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking, requiresMasking = item.RequiresMasking,
notes = item.Notes, notes = item.Notes,
@@ -443,8 +438,6 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")", "aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")", "aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)), "aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems", "itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")" "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
} }
+19 -135
View File
@@ -28,20 +28,6 @@
</div> </div>
</div> </div>
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
<div>
<strong>Rework Job</strong> &mdash; This job was created to redo work from
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
</a>.
All costs for this redo are tracked here separately from the original job.
</div>
</div>
}
<!-- Status Banner --> <!-- Status Banner -->
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4"> <div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i> <i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
@@ -358,8 +344,6 @@
<tr data-item-id="@item.Id"> <tr data-item-id="@item.Id">
<td> <td>
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span> <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()) @if (item.Coats != null && item.Coats.Any())
{ {
<br /> <br />
@@ -2205,88 +2189,6 @@
<div class="modal-body"> <div class="modal-body">
<div id="reworkAddForm"> <div id="reworkAddForm">
<div class="row g-3"> <div class="row g-3">
<!-- Step 1: Are parts back in the shop? -->
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
<i class="bi bi-briefcase me-1"></i>Parts are back &mdash; create a Rework Job in the shop
</label>
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
</div>
</div>
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
<div class="border rounded p-3 bg-light">
<div class="mb-3">
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
<div id="rwItemCheckboxes">
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<div class="form-check">
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
</div>
}
}
</div>
</div>
<div>
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
<div class="row g-2">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
<label class="form-check-label" for="rwPricingShopFault">
<strong>Shop Fault</strong>
<span class="text-muted small d-block">Our mistake &mdash; rework job priced at $0.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
<label class="form-check-label" for="rwPricingCustomerReduced">
<strong>Customer &mdash; Reduced Rate</strong>
<span class="text-muted small d-block">Customer caused it but we&rsquo;re helping out &mdash; prices copied, edit after creation.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
<label class="form-check-label" for="rwPricingCustomerFull">
<strong>Customer &mdash; Full Price</strong>
<span class="text-muted small d-block">Customer caused it &mdash; original pricing applies.</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="rwSpecificItemRow" class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<hr class="my-0" />
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label> <label class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="rwType"> <select class="form-select" id="rwType">
@@ -2313,6 +2215,19 @@
<label class="form-label">Defect Description <span class="text-danger">*</span></label> <label class="form-label">Defect Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea> <textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
</div> </div>
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Discovered By</label> <label class="form-label">Discovered By</label>
<select class="form-select" id="rwDiscoveredBy"> <select class="form-select" id="rwDiscoveredBy">
@@ -2489,9 +2404,7 @@
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"itemsFieldPrefix": "JobItems", "itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")", "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
} }
</script> </script>
@@ -2741,20 +2654,9 @@
document.getElementById('rwBillingNotes').value = ''; document.getElementById('rwBillingNotes').value = '';
document.getElementById('rwReportedBy').value = ''; document.getElementById('rwReportedBy').value = '';
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0]; document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
// Reset rework job creation section
document.getElementById('rwCreateJobToggle').checked = false;
document.getElementById('rwCreateJobOptions').style.display = 'none';
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
modal.show(); modal.show();
} }
function toggleCreateJob(on) {
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
if (!on) document.getElementById('rwJobItem').value = '';
}
function openEdit(id) { function openEdit(id) {
editId = id; editId = id;
const r = records.find(x => x.id === id); const r = records.find(x => x.id === id);
@@ -2783,23 +2685,9 @@
// Create // Create
const defect = document.getElementById('rwDefect').value.trim(); const defect = document.getElementById('rwDefect').value.trim();
if (!defect) { alert('Defect description is required.'); return; } if (!defect) { alert('Defect description is required.'); return; }
const createJob = document.getElementById('rwCreateJobToggle').checked;
const selectedItemIds = createJob
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
: null;
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
alert('Select at least one item to include in the rework job.'); return;
}
if (createJob && !pricingRadio) {
alert('Select who is responsible for this rework.'); return;
}
const dto = { const dto = {
jobId: jid, jobId: jid,
jobItemId: createJob ? null : (document.getElementById('rwJobItem').value || null), jobItemId: document.getElementById('rwJobItem').value || null,
reworkType: parseInt(document.getElementById('rwType').value), reworkType: parseInt(document.getElementById('rwType').value),
reason: parseInt(document.getElementById('rwReason').value), reason: parseInt(document.getElementById('rwReason').value),
defectDescription: defect, defectDescription: defect,
@@ -2808,10 +2696,7 @@
reportedByName: document.getElementById('rwReportedBy').value || null, reportedByName: document.getElementById('rwReportedBy').value || null,
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0, estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
isBillableToCustomer: document.getElementById('rwBillable').checked, isBillableToCustomer: document.getElementById('rwBillable').checked,
billingNotes: document.getElementById('rwBillingNotes').value || null, billingNotes: document.getElementById('rwBillingNotes').value || null
createReworkJob: createJob,
reworkJobItemIds: selectedItemIds,
reworkPricingType: pricingRadio ? parseInt(pricingRadio.value) : null
}; };
const resp = await fetch('/Jobs/AddReworkRecord', { const resp = await fetch('/Jobs/AddReworkRecord', {
method: 'POST', method: 'POST',
@@ -2856,7 +2741,7 @@
} }
load(); load();
return { load, openAdd, openEdit, save, del, toggleCreateJob }; return { load, openAdd, openEdit, save, del };
})(); })();
</script> </script>
@@ -3021,8 +2906,8 @@
function updateTotals(total) { function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '&mdash;'; const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '&mdash;';
document.getElementById('totalHoursDisplay').innerHTML = fmt; document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '&mdash;'; document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '&mdash;';
} }
// -- Modal helpers ------------------------------------------------- // -- Modal helpers -------------------------------------------------
@@ -3360,4 +3245,3 @@
</div> </div>
</div> </div>
</div> </div>
@@ -384,9 +384,6 @@
isGenericItem = item.IsGenericItem, isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem, isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem, isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting, requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking, requiresMasking = item.RequiresMasking,
notes = item.Notes, notes = item.Notes,
@@ -428,8 +425,6 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")", "aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")", "aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)), "aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "JobItems", "itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")" "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
} }
@@ -137,9 +137,6 @@
isGenericItem = item.IsGenericItem, isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem, isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem, isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting, requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking, requiresMasking = item.RequiresMasking,
notes = item.Notes, notes = item.Notes,
@@ -179,9 +176,7 @@
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)), "useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")", "pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
"itemsFieldPrefix": "JobItems", "itemsFieldPrefix": "JobItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")", "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
} }
</script> </script>
@@ -483,10 +483,10 @@
<tr> <tr>
<td style="text-align: center;">@coat.Sequence</td> <td style="text-align: center;">@coat.Sequence</td>
<td><strong>@coat.CoatName</strong></td> <td><strong>@coat.CoatName</strong></td>
<td>@Html.Raw(coat.ColorName ?? "&mdash;")</td> <td>@(coat.ColorName ?? "&mdash;")</td>
<td>@Html.Raw(coat.ColorCode ?? "&mdash;")</td> <td>@(coat.ColorCode ?? "&mdash;")</td>
<td>@Html.Raw(coat.Finish ?? "&mdash;")</td> <td>@(coat.Finish ?? "&mdash;")</td>
<td>@Html.Raw(coat.VendorName ?? "&mdash;")</td> <td>@(coat.VendorName ?? "&mdash;")</td>
<td> <td>
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) @if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{ {
@@ -561,7 +561,7 @@
<tr onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'" <tr onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'"
style="cursor: pointer;"> style="cursor: pointer;">
<td> <td>
<strong>@Html.Raw(item.Equipment?.EquipmentName ?? "&mdash;")</strong> <strong>@(item.Equipment?.EquipmentName ?? "&mdash;")</strong>
@if (!string.IsNullOrEmpty(item.Equipment?.Location)) @if (!string.IsNullOrEmpty(item.Equipment?.Location))
{ {
<br /><small class="text-muted"><i class="bi bi-geo-alt me-1"></i>@item.Equipment.Location</small> <br /><small class="text-muted"><i class="bi bi-geo-alt me-1"></i>@item.Equipment.Location</small>
@@ -119,7 +119,7 @@
<dt class="col-5 text-muted">Posted</dt> <dt class="col-5 text-muted">Posted</dt>
<dd class="col-7 small"> <dd class="col-7 small">
@Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br /> @Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br />
<span class="text-muted">by @Html.Raw(Model.PostedBy ?? "&mdash;")</span> <span class="text-muted">by @(Model.PostedBy ?? "&mdash;")</span>
</dd> </dd>
} }
<dt class="col-5 text-muted">Created</dt> <dt class="col-5 text-muted">Created</dt>
@@ -188,16 +188,16 @@
@{ @{
var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt; var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
} }
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;") @(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
</td> </td>
<td class="text-muted small"> <td class="text-muted small">
@Html.Raw(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;") @(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td> </td>
<td class="text-muted small"> <td class="text-muted small">
@Html.Raw(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;") @(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td> </td>
<td class="text-muted small"> <td class="text-muted small">
@Html.Raw(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;") @(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td> </td>
<td class="text-center"> <td class="text-center">
@switch (row.Status) @switch (row.Status)
@@ -23,7 +23,7 @@
<div class="card-body"> <div class="card-body">
<dl class="row small mb-0"> <dl class="row small mb-0">
<dt class="col-5 text-muted">Company</dt> <dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@Html.Raw(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "&mdash;"))</dd> <dd class="col-7">@(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "&mdash;"))</dd>
<dt class="col-5 text-muted">Type</dt> <dt class="col-5 text-muted">Type</dt>
<dd class="col-7">@Model.NotificationType</dd> <dd class="col-7">@Model.NotificationType</dd>
<dt class="col-5 text-muted">Channel</dt> <dt class="col-5 text-muted">Channel</dt>
@@ -188,13 +188,13 @@
<tr> <tr>
<td class="text-muted">Monthly ID</td> <td class="text-muted">Monthly ID</td>
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdMonthly"> <td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdMonthly">
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "&mdash;" : plan.StripePriceIdMonthly) @(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "&mdash;" : plan.StripePriceIdMonthly)
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="text-muted">Annual ID</td> <td class="text-muted">Annual ID</td>
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdAnnual"> <td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdAnnual">
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "&mdash;" : plan.StripePriceIdAnnual) @(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "&mdash;" : plan.StripePriceIdAnnual)
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -115,7 +115,7 @@
<td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td> <td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td>
<td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td> <td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td>
<td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")"> <td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")">
@Html.Raw(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "&mdash;") @(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "&mdash;")
</td> </td>
<td class="text-center">@item.ActiveJobCount</td> <td class="text-center">@item.ActiveJobCount</td>
<td class="text-center"> <td class="text-center">
@@ -344,7 +344,7 @@
<br /><small class="text-muted">@w.CoatName</small> <br /><small class="text-muted">@w.CoatName</small>
</td> </td>
<td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td> <td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td>
<td>@Html.Raw(w.Complexity ?? "&mdash;")</td> <td>@(w.Complexity ?? "&mdash;")</td>
<td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td> <td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td>
<td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td> <td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td>
<td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td> <td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td>
@@ -28,7 +28,7 @@ else
<span class="text-muted"> · @coat.ColorName</span> <span class="text-muted"> · @coat.ColorName</span>
} }
</td> </td>
<td class="text-end small">@Html.Raw(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "&mdash;")</td> <td class="text-end small">@(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "&mdash;")</td>
<td class="text-end small"> <td class="text-end small">
@if (coat.IsRecorded) @if (coat.IsRecorded)
{ {
@@ -61,10 +61,10 @@ else
<td>Total</td> <td>Total</td>
<td class="text-end">@Model.TotalEstimatedLbs.ToString("0.##") lbs</td> <td class="text-end">@Model.TotalEstimatedLbs.ToString("0.##") lbs</td>
<td class="text-end @(Model.TotalActualLbs > 0 ? "text-success" : "")"> <td class="text-end @(Model.TotalActualLbs > 0 ? "text-success" : "")">
@Html.Raw(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "&mdash;") @(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "&mdash;")
</td> </td>
<td class="text-end @(Model.TotalVarianceLbs > 0 ? "text-danger" : Model.TotalVarianceLbs < 0 ? "text-success" : "")"> <td class="text-end @(Model.TotalVarianceLbs > 0 ? "text-danger" : Model.TotalVarianceLbs < 0 ? "text-success" : "")">
@Html.Raw(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "&mdash;") @(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "&mdash;")
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -93,7 +93,7 @@
<strong>@tier.TierName</strong> <strong>@tier.TierName</strong>
</td> </td>
<td> <td>
<span class="text-muted small">@Html.Raw(tier.Description ?? "&mdash;")</span> <span class="text-muted small">@(tier.Description ?? "&mdash;")</span>
</td> </td>
<td class="text-center"> <td class="text-center">
@if (tier.DiscountPercent == 0) @if (tier.DiscountPercent == 0)
@@ -256,7 +256,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">Expected Delivery</span> <span class="text-muted">Expected Delivery</span>
<span class="@(Model.IsOverdue ? "text-danger fw-semibold" : "")"> <span class="@(Model.IsOverdue ? "text-danger fw-semibold" : "")">
@Html.Raw(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;") @(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")
</span> </span>
</div> </div>
@if (Model.ReceivedDate.HasValue) @if (Model.ReceivedDate.HasValue)
@@ -260,7 +260,7 @@
</span> </span>
</td> </td>
<td>@po.OrderDate.ToString("MM/dd/yyyy")</td> <td>@po.OrderDate.ToString("MM/dd/yyyy")</td>
<td>@Html.Raw(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td> <td>@(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-center">@po.ItemCount</td> <td class="text-center">@po.ItemCount</td>
<td class="text-end fw-semibold">$@po.TotalAmount.ToString("N2")</td> <td class="text-end fw-semibold">$@po.TotalAmount.ToString("N2")</td>
<td class="text-end"> <td class="text-end">
@@ -464,11 +464,6 @@
manualUnitPrice = item.ManualUnitPrice, manualUnitPrice = item.ManualUnitPrice,
isGenericItem = item.IsGenericItem, isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem, isLaborItem = item.IsLaborItem,
isSalesItem = item.IsSalesItem,
isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
requiresSandblasting = item.RequiresSandblasting, requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking, requiresMasking = item.RequiresMasking,
notes = item.Notes, notes = item.Notes,
@@ -510,8 +505,6 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")", "aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")", "aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)), "aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems", "itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")" "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
} }
@@ -1300,7 +1300,6 @@
<span class="fw-semibold">@(item.Description ?? item.CatalogItemName ?? "(no description)")</span> <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.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.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) @if (item.SurfaceAreaSqFt > 0 || item.EstimatedMinutes > 0)
{ {
<span class="text-muted ms-2" style="font-size:.8rem;"> <span class="text-muted ms-2" style="font-size:.8rem;">
@@ -501,9 +501,6 @@
isGenericItem = item.IsGenericItem, isGenericItem = item.IsGenericItem,
isLaborItem = item.IsLaborItem, isLaborItem = item.IsLaborItem,
isAiItem = item.IsAiItem, isAiItem = item.IsAiItem,
isCustomFormulaItem = item.IsCustomFormulaItem,
customItemTemplateId = item.CustomItemTemplateId,
formulaFieldValuesJson = item.FormulaFieldValuesJson,
includePrepCost = item.IncludePrepCost, includePrepCost = item.IncludePrepCost,
complexity = item.Complexity, complexity = item.Complexity,
aiTags = item.AiTags, aiTags = item.AiTags,
@@ -551,8 +548,6 @@
"aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")", "aiUploadUrl": "@Url.Action("UploadAiPhoto", "Quotes")",
"aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")", "aiAnalyzeUrl": "@Url.Action("AiAnalyzeItem", "Quotes")",
"aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)), "aiPhotoQuotesEnabled": @Json.Serialize((bool)(ViewBag.AiPhotoQuotesEnabled ?? true)),
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")",
"itemsFieldPrefix": "QuoteItems", "itemsFieldPrefix": "QuoteItems",
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")", "aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
"emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())), "emailOptOutCustomerIds": @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.CustomerEmailOptOutIds ?? new System.Collections.Generic.HashSet<int>())),
@@ -156,11 +156,11 @@ else
</a> </a>
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span> <span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
</td> </td>
<td class="text-end aging-current">@Html.Raw(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "&mdash;")</td> <td class="text-end aging-current">@(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@Html.Raw(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "&mdash;")</td> <td class="text-end aging-1-30">@(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@Html.Raw(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "&mdash;")</td> <td class="text-end aging-31-60">@(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@Html.Raw(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "&mdash;")</td> <td class="text-end aging-61-90">@(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@Html.Raw(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "&mdash;")</td> <td class="text-end aging-over90">@(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td> <td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
</tr> </tr>
} }
@@ -218,7 +218,7 @@ else
</a> </a>
</td> </td>
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td> <td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@Html.Raw(bill.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td> <td class="text-muted small">@(bill.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td> <td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td> <td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td> <td></td>
@@ -156,11 +156,11 @@ else
</a> </a>
<span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span> <span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span>
</td> </td>
<td class="text-end aging-current">@Html.Raw(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "&mdash;")</td> <td class="text-end aging-current">@(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@Html.Raw(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "&mdash;")</td> <td class="text-end aging-1-30">@(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@Html.Raw(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "&mdash;")</td> <td class="text-end aging-31-60">@(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@Html.Raw(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "&mdash;")</td> <td class="text-end aging-61-90">@(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@Html.Raw(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "&mdash;")</td> <td class="text-end aging-over90">@(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td> <td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
</tr> </tr>
} }
@@ -218,7 +218,7 @@ else
</a> </a>
</td> </td>
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td> <td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@Html.Raw(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td> <td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td> <td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td> <td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td> <td></td>
@@ -101,11 +101,11 @@
@item.CustomerName @item.CustomerName
</a> </a>
</td> </td>
<td class="small">@Html.Raw(item.Email ?? "&mdash;")</td> <td class="small">@(item.Email ?? "&mdash;")</td>
<td class="small">@Html.Raw(item.Phone ?? "&mdash;")</td> <td class="small">@(item.Phone ?? "&mdash;")</td>
<td class="text-end">@item.TotalJobs</td> <td class="text-end">@item.TotalJobs</td>
<td class="text-end">@item.LifetimeRevenue.ToString("C")</td> <td class="text-end">@item.LifetimeRevenue.ToString("C")</td>
<td>@Html.Raw(item.LastJobDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td> <td>@(item.LastJobDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-end"> <td class="text-end">
@if (item.DaysSinceLastJob < 0) @if (item.DaysSinceLastJob < 0)
{ {

Some files were not shown because too many files have changed in this diff Show More