Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7020797a25 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 |
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
||||
public GiftCertificateStatus Status { get; set; }
|
||||
public DateTime IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
}
|
||||
|
||||
public class GiftCertificateDto : GiftCertificateListDto
|
||||
|
||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
||||
|
||||
public decimal SubtotalBeforeDiscount { get; set; }
|
||||
|
||||
public decimal PricingTierDiscountAmount { get; set; }
|
||||
public decimal PricingTierDiscountPercent { get; set; }
|
||||
public decimal QuoteDiscountAmount { get; set; }
|
||||
public decimal QuoteDiscountPercent { get; set; }
|
||||
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -113,7 +113,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
|
||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
|
||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||
public int? SourceInvoiceItemId { get; set; }
|
||||
|
||||
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Customer? RecipientCustomer { get; set; }
|
||||
public virtual Customer? PurchasingCustomer { get; set; }
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Job : BaseEntity
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||
public string? PricingBreakdownJson { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
||||
public DateTime? ApprovedDate { get; set; }
|
||||
|
||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
Generated
+10754
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGiftCertificateBatchId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "BatchId",
|
||||
table: "GiftCertificates",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BatchId",
|
||||
table: "GiftCertificates");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobPricingSnapshot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingBreakdownJson",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||
}
|
||||
}
|
||||
}
|
||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuotePricingSnapshotFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadCost",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FacilityOverheadRatePerHour",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PricingTierDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountAmount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteDiscountPercent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubtotalAfterDiscount",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3290,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("CertificateCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -4214,6 +4217,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("OvenCycleMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -6708,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6719,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6730,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482),
|
||||
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6977,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("ExpirationDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("HideDiscountFromCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7022,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ProfitMargin")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7061,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("QuoteDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("QuoteNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -7086,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("SubTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("SubtotalAfterDiscount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
|
||||
IssuedReason = gc.IssuedReason,
|
||||
Status = gc.Status,
|
||||
IssueDate = gc.IssueDate,
|
||||
ExpiryDate = gc.ExpiryDate
|
||||
ExpiryDate = gc.ExpiryDate,
|
||||
BatchId = gc.BatchId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -487,7 +488,7 @@ public class GiftCertificatesController : Controller
|
||||
discountAcctId = acct?.Id;
|
||||
}
|
||||
|
||||
var createdIds = new List<int>();
|
||||
var batchId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < dto.Quantity; i++)
|
||||
@@ -507,12 +508,12 @@ public class GiftCertificatesController : Controller
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = currentUser?.Email
|
||||
CreatedBy = currentUser?.Email,
|
||||
BatchId = batchId
|
||||
};
|
||||
|
||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
createdIds.Add(cert.Id);
|
||||
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
@@ -521,10 +522,7 @@ public class GiftCertificatesController : Controller
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
|
||||
}
|
||||
|
||||
TempData["BulkCertIds"] = System.Text.Json.JsonSerializer.Serialize(createdIds);
|
||||
TempData["BulkCertCount"] = dto.Quantity;
|
||||
TempData["BulkCertAmount"] = dto.Amount.ToString("F2");
|
||||
return RedirectToAction(nameof(BulkResult));
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -535,31 +533,30 @@ public class GiftCertificatesController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the bulk creation confirmation page with the list of generated certificate codes
|
||||
/// and a button to download all as a single print-ready PDF.
|
||||
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
|
||||
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BulkResult()
|
||||
public async Task<IActionResult> BulkResult(Guid batchId)
|
||||
{
|
||||
if (TempData["BulkCertIds"] is not string json)
|
||||
if (batchId == Guid.Empty)
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
var ids = System.Text.Json.JsonSerializer.Deserialize<List<int>>(json) ?? new();
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(gc => ids.Contains(gc.Id), false);
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.BatchId == batchId, false);
|
||||
|
||||
if (!certs.Any())
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
ViewBag.CertCount = TempData["BulkCertCount"];
|
||||
ViewBag.CertAmount = TempData["BulkCertAmount"];
|
||||
ViewBag.CertIds = ids;
|
||||
return View(certs.OrderBy(c => c.CertificateCode).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates and streams a single PDF containing one page per certificate in the batch.
|
||||
/// The certificate IDs are posted as a form array so there is no URL-length limit on batch size.
|
||||
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
|
||||
/// user can bookmark or re-open it at any time after the batch was originally created.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> BulkDownloadPdf(int[] certIds)
|
||||
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
|
||||
{
|
||||
if (certIds == null || certIds.Length == 0)
|
||||
if (batchId == Guid.Empty)
|
||||
return BadRequest();
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
@@ -578,9 +575,12 @@ public class GiftCertificatesController : Controller
|
||||
};
|
||||
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => certIds.Contains(gc.Id), false,
|
||||
gc => gc.BatchId == batchId, false,
|
||||
gc => gc.RecipientCustomer);
|
||||
|
||||
if (!certs.Any())
|
||||
return NotFound();
|
||||
|
||||
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
|
||||
{
|
||||
Id = cert.Id,
|
||||
@@ -603,18 +603,18 @@ public class GiftCertificatesController : Controller
|
||||
{
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
|
||||
var firstName = dtos.FirstOrDefault()?.CertificateCode ?? "batch";
|
||||
var lastName = dtos.LastOrDefault()?.CertificateCode ?? string.Empty;
|
||||
var first = dtos.First().CertificateCode;
|
||||
var last = dtos.Last().CertificateCode;
|
||||
var fileName = dtos.Count == 1
|
||||
? $"GiftCertificate-{firstName}.pdf"
|
||||
: $"GiftCertificates-{firstName}-to-{lastName}.pdf";
|
||||
? $"GiftCertificate-{first}.pdf"
|
||||
: $"GiftCertificates-{first}-to-{last}.pdf";
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating bulk gift certificate PDF");
|
||||
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
|
||||
TempData["Error"] = "Could not generate PDF.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
SourceJobItemId = item.Id,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
Description = item.Description ?? "Powder Coating",
|
||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||
UnitPrice = item.UnitPrice,
|
||||
TotalPrice = item.TotalPrice,
|
||||
ColorName = item.ColorName,
|
||||
Notes = item.Notes,
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = revenueAccountId
|
||||
});
|
||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||
if (sourceQuote != null)
|
||||
{
|
||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||
var processingFees = sourceQuote.OvenBatchCost
|
||||
+ sourceQuote.FacilityOverheadCost
|
||||
+ sourceQuote.ShopSuppliesAmount
|
||||
+ sourceQuote.RushFee;
|
||||
|
||||
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
|
||||
}
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
// Direct job — no source quote. Read all charges from the pricing snapshot so the
|
||||
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Oven Processing Fee",
|
||||
Description = "Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||
if (facilityOverhead > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Facility Overhead",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||
if (rushFee > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = "Rush Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(rushFee, 2),
|
||||
TotalPrice = Math.Round(rushFee, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
|
||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = ji.EstimatedMinutes,
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Complexity = ji.Complexity,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
if (breakdownItems.Any())
|
||||
// Display the pricing snapshot stored when items were last saved.
|
||||
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||
{
|
||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
breakdownItems, job.CompanyId, job.CustomerId,
|
||||
wizardCosts?.TaxPercent ?? 0m,
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||
}
|
||||
else if (job.FinalPrice > 0)
|
||||
{
|
||||
// Legacy job created before snapshot was introduced — show what we have stored
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
OvenBatchCost = job.OvenBatchCost,
|
||||
OvenBatches = job.OvenBatches,
|
||||
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||
Total = job.FinalPrice
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
@@ -1169,15 +1121,23 @@ public class JobsController : Controller
|
||||
|
||||
// Recalculate total from wizard items
|
||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? createOvenRate = null;
|
||||
if (dto.OvenCostId.HasValue)
|
||||
{
|
||||
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
|
||||
if (createOven != null && createOven.CompanyId == companyId)
|
||||
createOvenRate = createOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1629,14 +1589,22 @@ public class JobsController : Controller
|
||||
if (dto.JobItems.Any())
|
||||
{
|
||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||
decimal? editOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (editOven != null && editOven.CompanyId == companyId)
|
||||
editOvenRate = editOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2962,7 +2930,7 @@ public class JobsController : Controller
|
||||
JobId = job.Id,
|
||||
JobNumber = job.JobNumber,
|
||||
CustomerId = job.CustomerId,
|
||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
||||
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
OvenCostId = job.OvenCostId,
|
||||
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||
@@ -2999,7 +2967,7 @@ public class JobsController : Controller
|
||||
{
|
||||
ModelState.AddModelError("", "Please add at least one job item.");
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||
@@ -3044,15 +3012,26 @@ public class JobsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||
decimal? ovenRateOverride = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (oven != null && oven.CompanyId == currentUser.CompanyId)
|
||||
ovenRateOverride = oven.CostPerHour;
|
||||
}
|
||||
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3066,7 +3045,7 @@ public class JobsController : Controller
|
||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||
TempData["Error"] = "An error occurred while saving job items.";
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||
return View("EditItems", model);
|
||||
}
|
||||
@@ -3108,31 +3087,47 @@ public class JobsController : Controller
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem,
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
IsAiItem = ji.IsAiItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice,
|
||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
if (remainingDtos.Any())
|
||||
{
|
||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||
decimal? deleteOvenRate = null;
|
||||
if (job.OvenCostId.HasValue)
|
||||
{
|
||||
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
|
||||
deleteOvenRate = deleteOven.CostPerHour;
|
||||
}
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.OvenBatchCost = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
job.PricingBreakdownJson = null;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3242,6 +3237,57 @@ public class JobsController : Controller
|
||||
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
|
||||
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||
/// are never charged tax when a job is saved or recalculated.
|
||||
/// </summary>
|
||||
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||
{
|
||||
if (customerId is > 0)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||
if (customer?.IsTaxExempt == true) return 0m;
|
||||
}
|
||||
return companyDefaultRate;
|
||||
}
|
||||
|
||||
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||
new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes,
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = pr.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = pr.QuoteDiscountPercent,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#region Item Pricing (AJAX)
|
||||
|
||||
@@ -916,8 +916,13 @@ public class KioskController : Controller
|
||||
ViewBag.SessionToken = session.SessionToken;
|
||||
ViewBag.SessionType = session.SessionType;
|
||||
|
||||
// Reset to Welcome screen after 45 s of inactivity on any intake step.
|
||||
// The Welcome screen itself stays on indefinitely (no timeout override there).
|
||||
ViewBag.InactivityTimeoutMs = 45_000;
|
||||
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
|
||||
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
|
||||
// Remote sessions: customer is on their own phone — never redirect; they may
|
||||
// take several minutes between steps and have no KioskDevice cookie anyway.
|
||||
if (session.SessionType == KioskSessionType.InPerson)
|
||||
ViewBag.InactivityTimeoutMs = 45_000;
|
||||
else
|
||||
ViewBag.ShowInactivityTimer = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutoMapper;
|
||||
using System.Text.Json;
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -2847,8 +2848,39 @@ public class QuotesController : Controller
|
||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||
QuotedPrice = quote.Total,
|
||||
FinalPrice = quote.Total,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
|
||||
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
|
||||
QuoteDiscountAmount = quote.QuoteDiscountAmount,
|
||||
QuoteDiscountPercent = quote.QuoteDiscountPercent,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
|
||||
@@ -109,6 +109,69 @@
|
||||
<span class="fw-semibold">Per-Company Breakdown</span>
|
||||
<span class="text-muted small">@Model.Rows.Count companies total</span>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
|
||||
<i class="bi bi-robot"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
|
||||
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Today</span>
|
||||
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">30 Days</span>
|
||||
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">All Time</span>
|
||||
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
|
||||
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>—</span> }
|
||||
</span>
|
||||
</div>
|
||||
@if (row.TopFeature != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Top Feature</span>
|
||||
<span class="mobile-card-value">
|
||||
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Tier</span>
|
||||
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-building me-1"></i>Company
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
|
||||
No AI usage logged yet.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -176,6 +176,60 @@
|
||||
<div class="card-body">
|
||||
@if (Model.Items.Any())
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var appointment in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-calendar-event"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@appointment.Title</h6>
|
||||
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} – {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">
|
||||
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(appointment.CustomerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Customer</span>
|
||||
<span class="mobile-card-value">@appointment.CustomerName</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
|
||||
@@ -21,6 +21,64 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var br in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
|
||||
<i class="bi bi-bank"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@br.Account?.Name</h6>
|
||||
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<span class="badge bg-success">Completed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Ending Balance</span>
|
||||
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
|
||||
</div>
|
||||
@if (br.CompletedAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Completed By</span>
|
||||
<span class="mobile-card-value">@br.CompletedBy</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
@if (br.Status == BankReconciliationStatus.Completed)
|
||||
{
|
||||
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Report
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check2-square me-1"></i>Continue
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -60,6 +60,59 @@
|
||||
<div class="card-body p-0">
|
||||
@if (active.Any())
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var ban in active)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Banned</span>
|
||||
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (ban.ExpiresAt.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Permanent</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-check-circle me-1"></i>Lift
|
||||
</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@@ -130,6 +183,55 @@
|
||||
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var ban in inactive)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@ban.IpAddress</h6>
|
||||
<small>
|
||||
@if (!ban.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Lifted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrEmpty(ban.Reason))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Reason</span>
|
||||
<span class="mobile-card-value text-muted">@ban.Reason</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Banned</span>
|
||||
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -101,6 +101,73 @@
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var m in Model)
|
||||
{
|
||||
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
|
||||
&& m.Status != CreditMemoStatus.FullyApplied
|
||||
&& m.Status != CreditMemoStatus.Voided;
|
||||
var (cmBadge, cmLabel) = m.Status switch
|
||||
{
|
||||
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
|
||||
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
|
||||
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
|
||||
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
|
||||
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
|
||||
};
|
||||
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
|
||||
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
|
||||
: m.Customer!.CompanyName;
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
|
||||
<i class="bi bi-journal-minus"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@m.MemoNumber</h6>
|
||||
<small>@cmCustomer</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @cmBadge">@cmLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Amount</span>
|
||||
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
|
||||
@m.RemainingBalance.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (m.ExpiryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
|
||||
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
|
||||
@if (expired2) { <small>(Expired)</small> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -118,6 +118,63 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var a in Model)
|
||||
{
|
||||
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
|
||||
<i class="bi bi-building-gear"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@a.Name</h6>
|
||||
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (a.IsDisposed)
|
||||
{
|
||||
<span class="badge bg-secondary">Disposed</span>
|
||||
}
|
||||
else if (fd)
|
||||
{
|
||||
<span class="badge bg-light text-dark border">Fully Depreciated</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Cost</span>
|
||||
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Book Value</span>
|
||||
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
|
||||
@a.BookValue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Monthly Depr.</span>
|
||||
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -2,41 +2,77 @@
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Batch Created";
|
||||
ViewData["Title"] = "Batch Gift Certificates";
|
||||
ViewData["PageIcon"] = "bi-gift";
|
||||
var count = ViewBag.CertCount as int? ?? Model.Count;
|
||||
var amount = ViewBag.CertAmount as string ?? "0.00";
|
||||
var ids = ViewBag.CertIds as List<int> ?? Model.Select(c => c.Id).ToList();
|
||||
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
|
||||
var count = Model.Count;
|
||||
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
|
||||
}
|
||||
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>@count gift certificates created</strong> — each worth $@amount.
|
||||
Download the PDF below to print the full batch.
|
||||
<strong>@count gift certificates created</strong> — each worth @amount.ToString("C").
|
||||
Download the PDF below to print the full batch. This page is bookmarkable — you can return here any time to re-download.
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
|
||||
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]…</span>
|
||||
</h5>
|
||||
<form asp-action="BulkDownloadPdf" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
@foreach (var id in ids)
|
||||
{
|
||||
<input type="hidden" name="certIds" value="@id" />
|
||||
}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
|
||||
</button>
|
||||
</form>
|
||||
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
|
||||
<i class="bi bi-gift"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||
<small>@cert.OriginalAmount.ToString("C")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expiry</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Certificate Code</th>
|
||||
<th class="ps-3">Certificate Code</th>
|
||||
<th>Face Value</th>
|
||||
<th>Issued</th>
|
||||
<th>Expiry</th>
|
||||
@@ -48,7 +84,7 @@
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-mono fw-semibold">@cert.CertificateCode</td>
|
||||
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
|
||||
<td>@cert.OriginalAmount.ToString("C")</td>
|
||||
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
|
||||
<td>
|
||||
@@ -58,7 +94,7 @@
|
||||
</td>
|
||||
<td><span class="badge bg-success">Active</span></td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
|
||||
@@ -75,15 +111,8 @@
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
|
||||
</a>
|
||||
<form asp-action="BulkDownloadPdf" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
@foreach (var id in ids)
|
||||
{
|
||||
<input type="hidden" name="certIds" value="@id" />
|
||||
}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
|
||||
</button>
|
||||
</form>
|
||||
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
|
||||
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,73 @@ else
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var cert in Model)
|
||||
{
|
||||
var (gcBadge, gcLabel) = cert.Status switch
|
||||
{
|
||||
GiftCertificateStatus.Active => ("bg-success", "Active"),
|
||||
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
|
||||
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
|
||||
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
|
||||
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", cert.Status.ToString())
|
||||
};
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
|
||||
<i class="bi bi-gift"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace">@cert.CertificateCode</h6>
|
||||
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @gcBadge">@gcLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Face Value</span>
|
||||
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||
@cert.RemainingBalance.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Issued</span>
|
||||
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (cert.ExpiryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expires</span>
|
||||
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
|
||||
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
@if (cert.BatchId.HasValue)
|
||||
{
|
||||
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-collection me-1"></i>Batch
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
@@ -80,6 +147,14 @@ else
|
||||
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
|
||||
@cert.CertificateCode
|
||||
</a>
|
||||
@if (cert.BatchId.HasValue)
|
||||
{
|
||||
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
|
||||
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
|
||||
title="View & download batch">
|
||||
<i class="bi bi-collection me-1"></i>Batch
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(cert.RecipientName))
|
||||
@@ -88,7 +163,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
|
||||
{
|
||||
|
||||
@@ -29,6 +29,61 @@
|
||||
else
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var n in items)
|
||||
{
|
||||
bool mIsRead = (bool)n.IsRead;
|
||||
string mTitle = (string)n.Title;
|
||||
string mMessage = (string)n.Message;
|
||||
string? mLink = (string?)n.Link;
|
||||
string mType = (string)n.NotificationType;
|
||||
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
|
||||
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
|
||||
data-id="@n.Id"
|
||||
data-title="@mTitle"
|
||||
data-message="@mMessage"
|
||||
data-link="@(mLink ?? "")"
|
||||
data-type="@mType"
|
||||
data-is-read="@(mIsRead ? "1" : "0")"
|
||||
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
|
||||
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-bell"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
|
||||
@if (!mIsRead)
|
||||
{
|
||||
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
|
||||
}
|
||||
@mTitle
|
||||
</h6>
|
||||
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row" style="align-items:flex-start;">
|
||||
<span class="mobile-card-label">Message</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(mLink))
|
||||
{
|
||||
<div class="mobile-card-footer">
|
||||
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-arrow-right me-1"></i>Open
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -126,6 +126,77 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@{ lastMfr = null; }
|
||||
@foreach (var item in needOrder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||
{
|
||||
lastMfr = item.Manufacturer;
|
||||
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #64748b 0%, #475569 100%);">
|
||||
<i class="bi bi-palette"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Part #</span>
|
||||
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Finish</span>
|
||||
<span class="mobile-card-value">@item.Finish</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">In Stock</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (item.QuantityOnHand > 0)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">None</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-success btn-toggle-panel"
|
||||
data-item-id="@item.Id" data-has-panel="true">
|
||||
<i class="bi bi-check-lg me-1"></i>Got It
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="needTable">
|
||||
<thead class="table-group-divider">
|
||||
@@ -220,6 +291,68 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@{ lastMfr = null; }
|
||||
@foreach (var item in onHand)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
|
||||
{
|
||||
lastMfr = item.Manufacturer;
|
||||
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
|
||||
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #059669 0%, #047857 100%);">
|
||||
<i class="bi bi-palette"></i>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.ColorName ?? item.Name)</h6>
|
||||
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Part #</span>
|
||||
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Finish))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Finish</span>
|
||||
<span class="mobile-card-value">@item.Finish</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>On Wall</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<button class="btn btn-sm btn-outline-danger btn-toggle-panel"
|
||||
data-item-id="@item.Id" data-has-panel="false">
|
||||
<i class="bi bi-x-lg me-1"></i>Remove
|
||||
</button>
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-group-divider">
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</td>
|
||||
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-end">@inv.Total.ToString("C")</td>
|
||||
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||
@@ -167,6 +167,77 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var inv in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
|
||||
<div class="mobile-card-header" style="@(inv.IsOverdue ? "background:#fee2e2;" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-receipt"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@inv.InvoiceNumber</h6>
|
||||
<small>@inv.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
|
||||
</span>
|
||||
</div>
|
||||
@if (inv.JobId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Job</span>
|
||||
<span class="mobile-card-value">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
|
||||
class="text-decoration-none" onclick="event.stopPropagation()">
|
||||
@inv.JobNumber
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Date</span>
|
||||
<span class="mobile-card-value">@inv.InvoiceDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (inv.DueDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value @(inv.IsOverdue ? "fw-bold text-danger" : "")">
|
||||
@inv.DueDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value">@inv.Total.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Balance Due</span>
|
||||
<span class="mobile-card-value @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
|
||||
@inv.BalanceDue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@inv.Id"
|
||||
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
|
||||
class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3">
|
||||
@await Html.PartialAsync("_Pagination", Model)
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Shop work has started � review the quote and apply any changes manually.</span>
|
||||
<span>Shop work has started — review the quote and apply any changes manually.</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
@@ -217,7 +217,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@
|
||||
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
|
||||
</button>
|
||||
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,7 +273,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="workerAssignmentSelect" class="form-select form-select-sm"
|
||||
onchange="updateWorkerAssignment(this)">
|
||||
<option value="">� Unassigned �</option>
|
||||
<option value="">– Unassigned –</option>
|
||||
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
|
||||
{
|
||||
if (w.Value == Model.AssignedUserId)
|
||||
@@ -287,7 +287,7 @@
|
||||
}
|
||||
</select>
|
||||
<span id="workerSaveIndicator" class="text-muted small d-none">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>Saving…
|
||||
</span>
|
||||
<span id="workerSavedTick" class="text-success small d-none">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
@* ── Catalog Products ── *@
|
||||
@* -- Catalog Products -- *@
|
||||
@if (catalogItems.Any())
|
||||
{
|
||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||
@@ -351,10 +351,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<text> (@coat.VendorName)</text>
|
||||
@@ -373,7 +373,7 @@
|
||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||
@if (!coat.InventoryItemId.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||
@@ -390,7 +390,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
||||
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Notes))
|
||||
@@ -414,7 +414,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Custom Work ── *@
|
||||
@* -- Custom Work -- *@
|
||||
@if (customItems.Any())
|
||||
{
|
||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||
@@ -478,10 +478,10 @@
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">
|
||||
� <strong>@coat.CoatName</strong>
|
||||
• <strong>@coat.CoatName</strong>
|
||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
||||
{
|
||||
<text> � @coat.ColorName</text>
|
||||
<text> – @coat.ColorName</text>
|
||||
@if (!string.IsNullOrEmpty(coat.VendorName))
|
||||
{
|
||||
<text> (@coat.VendorName)</text>
|
||||
@@ -500,7 +500,7 @@
|
||||
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
|
||||
@if (!coat.InventoryItemId.HasValue)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Notes))
|
||||
@@ -517,7 +517,7 @@
|
||||
@foreach (var ps in item.PrepServices)
|
||||
{
|
||||
<br />
|
||||
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
|
||||
<small class="ms-3">• <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">– @ps.EstimatedMinutes min</span></small>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Notes))
|
||||
@@ -532,7 +532,7 @@
|
||||
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (item.EstimatedMinutes > 0)
|
||||
@@ -540,7 +540,7 @@
|
||||
<text>@item.EstimatedMinutes min</text>
|
||||
<br /><small class="text-muted">per item</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (totalPowderNeeded > 0)
|
||||
@@ -548,7 +548,7 @@
|
||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||
<br /><small class="text-muted">total batch</small>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -565,7 +565,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Labor ── *@
|
||||
@* -- Labor -- *@
|
||||
@if (laborItems.Any())
|
||||
{
|
||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||
@@ -599,7 +599,7 @@
|
||||
{
|
||||
<text>@item.EstimatedMinutes min</text>
|
||||
}
|
||||
else { <span class="text-muted">�</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
||||
@@ -616,7 +616,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Mobile cards ── *@
|
||||
@* -- Mobile cards -- *@
|
||||
<div class="d-lg-none mt-2">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
@@ -653,7 +653,7 @@
|
||||
<span class="mobile-card-value">
|
||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small>
|
||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -704,7 +704,7 @@
|
||||
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">Total: <strong id="totalHoursDisplay">�</strong></span>
|
||||
<span class="text-muted small">Total: <strong id="totalHoursDisplay">—</strong></span>
|
||||
@{
|
||||
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
|
||||
var estimatedHrs = estimatedMins / 60m;
|
||||
@@ -741,7 +741,7 @@
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">�</td>
|
||||
<td class="text-end" id="timeEntriesTotalHours">—</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1099,7 +1099,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="intakeModalLabel">
|
||||
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake � Check In
|
||||
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake – Check In
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
@@ -1117,7 +1117,7 @@
|
||||
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
|
||||
placeholder="@intakeExpectedCount" />
|
||||
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected � note the discrepancy below.
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected — note the discrepancy below.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -1310,7 +1310,7 @@
|
||||
<a asp-action="Intake" asp-route-id="@Model.Id"
|
||||
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
|
||||
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
|
||||
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
|
||||
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
|
||||
</a>
|
||||
}
|
||||
@{
|
||||
@@ -1368,7 +1368,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Summary (internal � d-print-none) -->
|
||||
<!-- Pricing Summary (internal - d-print-none) -->
|
||||
@{
|
||||
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
|
||||
}
|
||||
@@ -1400,7 +1400,7 @@
|
||||
@if (jobPb.OvenBatchCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" � {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
|
||||
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
|
||||
</div>
|
||||
}
|
||||
@@ -1518,7 +1518,7 @@
|
||||
}
|
||||
else if (allCatalog)
|
||||
{
|
||||
<div class="text-muted small fst-italic">All items use fixed catalog pricing � no per-category cost split available.</div>
|
||||
<div class="text-muted small fst-italic">All items use fixed catalog pricing — no per-category cost split available.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1547,7 +1547,7 @@
|
||||
@if (jobPb.FacilityOverheadCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr � estimated hours)</span>
|
||||
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
|
||||
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1712,11 +1712,11 @@
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
|
||||
<span class="fw-semibold" id="costingRevenue">�</span>
|
||||
<span class="fw-semibold" id="costingRevenue">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
|
||||
<span id="costingPowder">�</span>
|
||||
<span id="costingPowder">—</span>
|
||||
</div>
|
||||
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1725,7 +1725,7 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
|
||||
<span id="costingLabor">�</span>
|
||||
<span id="costingLabor">—</span>
|
||||
</div>
|
||||
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1734,12 +1734,12 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
|
||||
<span id="costingOven">�</span>
|
||||
<span id="costingOven">—</span>
|
||||
</div>
|
||||
<div id="costingReworkSection" style="display:none;">
|
||||
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
|
||||
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
|
||||
<span id="costingRework">�</span>
|
||||
<span id="costingRework">—</span>
|
||||
</div>
|
||||
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
|
||||
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
|
||||
@@ -1748,25 +1748,25 @@
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
|
||||
<span>Billed to Customer</span>
|
||||
<span id="costingReworkBilled">�</span>
|
||||
<span id="costingReworkBilled">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="d-flex justify-content-between small mb-1 ps-2">
|
||||
<span class="text-muted">Total Costs</span>
|
||||
<span id="costingTotal" class="text-danger">�</span>
|
||||
<span id="costingTotal" class="text-danger">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between fw-bold mb-1">
|
||||
<span>Gross Profit</span>
|
||||
<span id="costingProfit">�</span>
|
||||
<span id="costingProfit">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||
<span>Gross Margin</span>
|
||||
<span id="costingMargin">�</span>
|
||||
<span id="costingMargin">—</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted">
|
||||
<span>Margin vs Quote</span>
|
||||
<span id="costingQuotedMargin">�</span>
|
||||
<span id="costingQuotedMargin">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
|
||||
@@ -1869,7 +1869,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tags
|
||||
<small class="text-muted fw-normal ms-1">� colors, finish, or other keywords</small>
|
||||
<small class="text-muted fw-normal ms-1">– colors, finish, or other keywords</small>
|
||||
</label>
|
||||
<input type="hidden" id="photoTagsHidden" name="tags" />
|
||||
<div id="photoTagsContainer"></div>
|
||||
@@ -1948,7 +1948,7 @@
|
||||
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">� colors, finish, keywords</small></label>
|
||||
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">– colors, finish, keywords</small></label>
|
||||
<input type="hidden" id="editPhotoTagsHidden" />
|
||||
<div id="editPhotoTagsContainer"></div>
|
||||
</div>
|
||||
@@ -2000,7 +2000,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
||||
<textarea class="form-control" id="smsMessageText" rows="5"
|
||||
placeholder="Type your message�" maxlength="160"></textarea>
|
||||
placeholder="Type your message…" maxlength="160"></textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<div id="smsStopWarning" class="text-warning small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
|
||||
@@ -2012,7 +2012,7 @@
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
||||
Skip � don't send
|
||||
Skip — don't send
|
||||
</button>
|
||||
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
||||
<i class="bi bi-send me-1"></i>Send SMS
|
||||
@@ -2133,7 +2133,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Specific Item (optional)</label>
|
||||
<select class="form-select" id="rwJobItem">
|
||||
<option value="">� Whole Job �</option>
|
||||
<option value="">– Whole Job –</option>
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
@@ -2195,9 +2195,9 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Resolution</label>
|
||||
<select class="form-select" id="rwResolution">
|
||||
<option value="">� Pending �</option>
|
||||
<option value="0">Recoated � No Charge</option>
|
||||
<option value="1">Recoated � Billed to Customer</option>
|
||||
<option value="">– Pending –</option>
|
||||
<option value="0">Recoated — No Charge</option>
|
||||
<option value="1">Recoated — Billed to Customer</option>
|
||||
<option value="2">Customer Credited</option>
|
||||
<option value="3">Written Off</option>
|
||||
<option value="4">No Action Required</option>
|
||||
@@ -2256,7 +2256,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="teWorkerId">
|
||||
<option value="">� Select worker �</option>
|
||||
<option value="">– Select worker –</option>
|
||||
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
|
||||
{
|
||||
<option value="@w.Id">@w.Name</option>
|
||||
@@ -2275,7 +2275,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Stage / Task</label>
|
||||
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking�" list="stageOptions" />
|
||||
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking…" list="stageOptions" />
|
||||
<datalist id="stageOptions">
|
||||
<option value="Sandblasting"></option>
|
||||
<option value="Masking & Taping"></option>
|
||||
@@ -2290,7 +2290,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes�"></textarea>
|
||||
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes…"></textarea>
|
||||
</div>
|
||||
<div class="text-danger small d-none" id="teError"></div>
|
||||
</div>
|
||||
@@ -2332,7 +2332,7 @@
|
||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// ── Inline date editing ──────────────────────────────────────────────
|
||||
// -- Inline date editing ----------------------------------------------
|
||||
const jobId = @Model.Id;
|
||||
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
@@ -2433,7 +2433,7 @@
|
||||
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
|
||||
|
||||
|
||||
// ── Auto-submit after wizard saves an item ────────────────────────
|
||||
// -- Auto-submit after wizard saves an item ------------------------
|
||||
let itemsModified = false;
|
||||
|
||||
// Wrap wizardSave to set a flag before the modal hides
|
||||
@@ -2451,12 +2451,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delete confirmation modal ─────────────────────────────────────
|
||||
// -- Delete confirmation modal -------------------------------------
|
||||
let pendingDeleteItemId = -1;
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
// Delegated listener � handles all delete buttons via data attributes
|
||||
// Delegated listener -- handles all delete buttons via data attributes
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-delete-id]');
|
||||
if (!btn) return;
|
||||
@@ -2489,7 +2489,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
|
||||
<!-- -- Rework / Warranty ---------------------------------------------- -->
|
||||
<script>
|
||||
const rework = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2534,12 +2534,12 @@
|
||||
</div>
|
||||
<div class="small mt-1 text-muted">${r.defectDescription}</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '� ' + r.reportedByName : ''}
|
||||
Found: ${r.discoveredByDisplay} — ${new Date(r.discoveredDate).toLocaleDateString()}
|
||||
${r.reportedByName ? '– ' + r.reportedByName : ''}
|
||||
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
|
||||
</div>
|
||||
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
|
||||
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' � $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
||||
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' — $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
@@ -2645,7 +2645,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
|
||||
<!-- -- Job Costing ---------------------------------------------------- -->
|
||||
<script>
|
||||
const costing = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2685,7 +2685,7 @@
|
||||
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
|
||||
const rBody = document.getElementById('reworkCostLines');
|
||||
rBody.innerHTML = d.reworkLines.map(l => `<tr>
|
||||
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} � ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
||||
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} – ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
|
||||
} else {
|
||||
@@ -2701,14 +2701,14 @@
|
||||
|
||||
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
||||
document.getElementById('costingQuotedMargin').textContent =
|
||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '�';
|
||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—';
|
||||
|
||||
// Powder detail lines
|
||||
const pBody = document.getElementById('powderLines');
|
||||
pBody.innerHTML = d.hasPowderData
|
||||
? d.powderLines.map(l => `<tr>
|
||||
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs � ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||
|
||||
@@ -2716,14 +2716,14 @@
|
||||
const lBody = document.getElementById('laborLines');
|
||||
lBody.innerHTML = d.hasLaborData
|
||||
? d.laborLines.map(l => `<tr>
|
||||
<td class="text-muted">${l.worker}${l.stage ? ' � ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||
<td class="text-end text-nowrap">${l.hours}h � ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-muted">${l.worker}${l.stage ? ' – ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
||||
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
|
||||
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
|
||||
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||
|
||||
// Notes
|
||||
const notes = [];
|
||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items � edit the item and enter a surface area to calculate powder cost.');
|
||||
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items — edit the item and enter a surface area to calculate powder cost.');
|
||||
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
|
||||
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
|
||||
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
|
||||
@@ -2754,7 +2754,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
|
||||
<!-- -- Time Tracking --------------------------------------------------- -->
|
||||
<script>
|
||||
const timeTracking = (() => {
|
||||
const jid = @Model.Id;
|
||||
@@ -2762,7 +2762,7 @@
|
||||
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
|
||||
let entries = [];
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────
|
||||
// -- Load ----------------------------------------------------------
|
||||
async function load() {
|
||||
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
|
||||
entries = await r.json();
|
||||
@@ -2793,7 +2793,7 @@
|
||||
<td class="fw-semibold">${esc(e.workerName)}</td>
|
||||
<td class="small">${d}</td>
|
||||
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
|
||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">�</span>'}</td>
|
||||
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="small text-muted">${esc(e.notes ?? '')}</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
|
||||
@@ -2805,12 +2805,12 @@
|
||||
}
|
||||
|
||||
function updateTotals(total) {
|
||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '�';
|
||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
||||
document.getElementById('totalHoursDisplay').textContent = fmt;
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�';
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
// ── Modal helpers ─────────────────────────────────────────────────
|
||||
// -- Modal helpers -------------------------------------------------
|
||||
function openAdd() {
|
||||
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
|
||||
document.getElementById('teEntryId').value = '0';
|
||||
@@ -2917,7 +2917,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Deposits ─────────────────────────────────────────────────────────────
|
||||
// -- Deposits -------------------------------------------------------------
|
||||
// Note: antiForgeryToken() is already defined above in this script block
|
||||
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@@ -2931,7 +2931,7 @@
|
||||
}
|
||||
|
||||
if (errEl) errEl.classList.add('d-none');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving�'; }
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…'; }
|
||||
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
|
||||
@@ -2973,7 +2973,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collapsible sections ──────────────────────────────────────────────────
|
||||
// -- Collapsible sections --------------------------------------------------
|
||||
(function () {
|
||||
const storageKey = 'jobDetailCollapse_@Model.Id';
|
||||
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
|
||||
@@ -3012,7 +3012,7 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Part Intake Modal ─────────────────────────────────────────────────────
|
||||
// -- Part Intake Modal --------------------------------------------------
|
||||
(function () {
|
||||
const expectedCount = @intakeExpectedCount;
|
||||
const partCountInput = document.getElementById('intakePartCount');
|
||||
@@ -3105,7 +3105,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="templateName" class="form-control" required maxlength="100"
|
||||
placeholder="e.g. Wheel Refinish � Standard 4pc">
|
||||
placeholder="e.g. Wheel Refinish — Standard 4pc">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -71,6 +71,59 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var job in overdueJobs)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@job.JobNumber</h6>
|
||||
<small>@job.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass">@job.StatusDisplayName</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span></span>
|
||||
</div>
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Scheduled</span>
|
||||
<span class="mobile-card-value text-danger fw-bold">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value text-danger">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')">
|
||||
<i class="bi bi-flag"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
@@ -191,6 +244,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var job in Model)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);">
|
||||
<i class="bi bi-kanban"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@job.JobNumber</h6>
|
||||
<small>@job.CustomerName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass" id="status-badge-@job.JobId">@job.StatusDisplayName</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass priority-badge-@job.JobId">@job.PriorityDisplayName</span></span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-info"><i class="bi bi-person me-1"></i>@job.AssignedWorkerName</span></span>
|
||||
</div>
|
||||
}
|
||||
@if (job.ScheduledDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Scheduled</span>
|
||||
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (job.DueDate.HasValue)
|
||||
{
|
||||
var mJobOverdue = job.DueDate.Value.Date < DateTime.Today;
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value @(mJobOverdue ? "text-danger fw-bold" : "")">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')" title="Change Priority">
|
||||
<i class="bi bi-flag"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')" title="Assign Worker">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-check fs-1 d-block mb-2 opacity-25"></i>
|
||||
No jobs scheduled for @scheduledDate.ToString("MMMM dd, yyyy").
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" id="jobsTable">
|
||||
<thead>
|
||||
@@ -352,6 +473,65 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var item in maintenanceItems)
|
||||
{
|
||||
var mPriorityBg = item.Priority switch
|
||||
{
|
||||
MaintenancePriority.Critical => "danger",
|
||||
MaintenancePriority.High => "warning",
|
||||
MaintenancePriority.Normal => "info",
|
||||
_ => "secondary"
|
||||
};
|
||||
var mStatusBgM = item.Status == MaintenanceStatus.InProgress ? "success" : "primary";
|
||||
var mStatusLbl = item.Status == MaintenanceStatus.InProgress ? "In Progress" : "Scheduled";
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);">
|
||||
<i class="bi bi-tools"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@(item.Equipment?.EquipmentName ?? "Maintenance")</h6>
|
||||
<small>@item.MaintenanceType</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Priority</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@mPriorityBg">@item.Priority</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@mStatusBgM">@mStatusLbl</span></span>
|
||||
</div>
|
||||
@if (item.AssignedUser != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Worker</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-info text-dark"><i class="bi bi-person me-1"></i>@item.AssignedUser.FullName</span></span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(item.Description))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Desc.</span>
|
||||
<span class="mobile-card-value text-muted">@item.Description</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openMaintenanceWorkerModal(@item.Id, '@(item.AssignedUserId ?? "")', '@(item.Equipment?.EquipmentName ?? "Maintenance")')" title="Assign Worker">
|
||||
<i class="bi bi-person"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
|
||||
@@ -47,6 +47,68 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var je in Model)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = je.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>
|
||||
@je.EntryNumber
|
||||
@if (je.IsReversal)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">REV</span>
|
||||
}
|
||||
</h6>
|
||||
<small>@je.EntryDate.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (je.Status == JournalEntryStatus.Draft)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Draft</span>
|
||||
}
|
||||
else if (je.Status == JournalEntryStatus.Posted)
|
||||
{
|
||||
<span class="badge bg-success">Posted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Reversed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(je.Description))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Description</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@je.Description</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(je.Reference))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Reference</span>
|
||||
<span class="mobile-card-value">@je.Reference</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
|
||||
}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
|
||||
@@ -53,17 +53,116 @@
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var s in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
|
||||
<i class="bi bi-clipboard-check"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@s.CustomerFullName</h6>
|
||||
<small>@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
|
||||
{
|
||||
<span class="badge bg-success">Converted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Submitted)
|
||||
{
|
||||
<span class="badge bg-info text-dark">Submitted</span>
|
||||
}
|
||||
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (s.SessionType == KioskSessionType.InPerson)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-tablet me-1"></i>In-Person</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge" style="background:#ede9fe;color:#6d28d9;"><i class="bi bi-envelope me-1"></i>Remote</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Phone</span>
|
||||
<span class="mobile-card-value"><a href="tel:@s.CustomerPhone">@s.CustomerPhone</a></span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(s.CustomerEmail))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Email</span>
|
||||
<span class="mobile-card-value" style="white-space:normal;"><a href="mailto:@s.CustomerEmail">@s.CustomerEmail</a></span>
|
||||
</div>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Matched</span>
|
||||
<span class="mobile-card-value">
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="text-success">
|
||||
<i class="bi bi-person-check me-1"></i>Customer record
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
@if (s.LinkedJobId.HasValue)
|
||||
{
|
||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-briefcase me-1"></i>Job
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedQuoteId.HasValue)
|
||||
{
|
||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Quote
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th class="d-none d-md-table-cell">Date</th>
|
||||
<th>Customer</th>
|
||||
<th>Contact</th>
|
||||
<th>Project</th>
|
||||
<th>Type</th>
|
||||
<th class="d-none d-lg-table-cell">Contact</th>
|
||||
<th class="d-none d-lg-table-cell">Project</th>
|
||||
<th class="d-none d-sm-table-cell">Type</th>
|
||||
<th>Status</th>
|
||||
<th>SMS</th>
|
||||
<th class="d-none d-md-table-cell">SMS</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -71,7 +170,7 @@
|
||||
@foreach (var s in Model)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-nowrap text-muted small">
|
||||
<td class="text-nowrap text-muted small d-none d-md-table-cell">
|
||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||
</td>
|
||||
<td>
|
||||
@@ -82,8 +181,12 @@
|
||||
<i class="bi bi-person-check me-1"></i>Customer matched
|
||||
</a>
|
||||
}
|
||||
@* Show date inline on mobile since the Date column is hidden *@
|
||||
<div class="text-muted small d-md-none">
|
||||
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
|
||||
</div>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
<td class="small text-muted d-none d-lg-table-cell">
|
||||
@if (!string.IsNullOrEmpty(s.CustomerPhone))
|
||||
{
|
||||
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
|
||||
@@ -93,11 +196,11 @@
|
||||
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
|
||||
}
|
||||
</td>
|
||||
<td style="max-width:280px;">
|
||||
<td class="d-none d-lg-table-cell" style="max-width:280px;">
|
||||
<span class="text-truncate d-block" style="max-width:260px;"
|
||||
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-none d-sm-table-cell">
|
||||
@if (s.SessionType == KioskSessionType.InPerson)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary">
|
||||
@@ -129,7 +232,7 @@
|
||||
<span class="badge bg-secondary">Expired</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
@if (s.SmsOptIn)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
|
||||
@@ -143,19 +246,19 @@
|
||||
@if (s.LinkedJobId.HasValue)
|
||||
{
|
||||
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
|
||||
<i class="bi bi-briefcase me-1"></i>View Job
|
||||
<i class="bi bi-briefcase me-1"></i><span class="d-none d-sm-inline">View Job</span><span class="d-sm-none">Job</span>
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedQuoteId.HasValue)
|
||||
{
|
||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>View Quote
|
||||
<i class="bi bi-file-earmark-text me-1"></i><span class="d-none d-sm-inline">View Quote</span><span class="d-sm-none">Quote</span>
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
<i class="bi bi-person me-1"></i><span class="d-none d-sm-inline">Customer</span>
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -134,6 +134,67 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" : "linear-gradient(135deg, #06b6d4 0%, #0e7490 100%)");">
|
||||
<i class="bi @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "bi-envelope" : "bi-phone")"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@item.RecipientName</h6>
|
||||
<small>@item.Recipient</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Type</span>
|
||||
<span class="mobile-card-value">@item.NotificationTypeDisplay</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Sent</span>
|
||||
<span class="mobile-card-value">@item.SentAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm")</span>
|
||||
</div>
|
||||
@if (item.JobId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Job</span>
|
||||
<span class="mobile-card-value">@item.JobNumber</span>
|
||||
</div>
|
||||
}
|
||||
else if (item.QuoteId.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Quote</span>
|
||||
<span class="mobile-card-value">@item.QuoteNumber</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@{
|
||||
var (mStatusBadge, mStatusIcon) = item.Status switch
|
||||
{
|
||||
PowderCoating.Core.Enums.NotificationStatus.Sent => ("bg-success", "bi-check-circle"),
|
||||
PowderCoating.Core.Enums.NotificationStatus.Failed => ("bg-danger", "bi-x-circle"),
|
||||
_ => ("bg-secondary", "bi-dash-circle")
|
||||
};
|
||||
}
|
||||
<span class="badge @mStatusBadge"><i class="bi @mStatusIcon me-1"></i>@item.StatusDisplay</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -44,6 +44,91 @@
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
var oPct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Companies", new { id = row.CompanyId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-building"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CompanyName</h6>
|
||||
<small>
|
||||
@switch (row.Status)
|
||||
{
|
||||
case OnboardingStatus.Complete:
|
||||
<span class="badge bg-success">Complete</span>
|
||||
break;
|
||||
case OnboardingStatus.InProgress:
|
||||
<span class="badge bg-warning text-dark">In Progress</span>
|
||||
break;
|
||||
case OnboardingStatus.Dismissed:
|
||||
<span class="badge bg-secondary">Dismissed</span>
|
||||
break;
|
||||
default:
|
||||
<span class="badge bg-light text-muted border">Not Started</span>
|
||||
break;
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Wizard</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (row.WizardCompleted)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
<span class="text-success ms-1">Done</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-circle text-muted"></i>
|
||||
<span class="text-muted ms-1">Pending</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Milestones</span>
|
||||
<span class="mobile-card-value">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress" style="height:5px; width:60px;">
|
||||
<div class="progress-bar @(oPct == 100 ? "bg-success" : "bg-primary")" style="width:@oPct%"></div>
|
||||
</div>
|
||||
<small class="text-muted">@row.StepsCompleted/@row.TotalSteps</small>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
@{
|
||||
var oFirstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
|
||||
}
|
||||
@if (oFirstActivity.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">First Activity</span>
|
||||
<span class="mobile-card-value text-muted">@oFirstActivity.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-building me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-building fs-1 d-block mb-2 opacity-25"></i>
|
||||
No companies found.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0" id="onboardingTable">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -164,6 +164,56 @@
|
||||
<!-- Grid -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var po in Model.Items)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = po.Id })'">
|
||||
<div class="mobile-card-header" style="@(po.IsOverdue ? "background:#fee2e2;" : "")">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<i class="bi bi-cart-check"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@po.PoNumber @(po.IsOverdue ? " — Overdue" : "")</h6>
|
||||
<small>@po.VendorName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@StatusBadge(po.Status)">@po.Status</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Order Date</span>
|
||||
<span class="mobile-card-value">@po.OrderDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
@if (po.ExpectedDeliveryDate.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Expected</span>
|
||||
<span class="mobile-card-value @(po.IsOverdue ? "text-danger fw-semibold" : "")">
|
||||
@po.ExpectedDeliveryDate.Value.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Items</span>
|
||||
<span class="mobile-card-value">@po.ItemCount</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value fw-semibold">$@po.TotalAmount.ToString("N2")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@po.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
if (taxField) {
|
||||
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
|
||||
}
|
||||
// Recalculate the live preview so it reflects the updated tax rate immediately
|
||||
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
|
||||
|
||||
const noEmail = customerId > 0 && noEmailIds.has(customerId);
|
||||
const emailSection = document.getElementById('emailNotifySection');
|
||||
|
||||
@@ -38,6 +38,96 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var t in Model)
|
||||
{
|
||||
var isOverdueRT = t.IsActive && t.NextFireDate.Date < DateTime.Today;
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@t.Name</h6>
|
||||
<small>
|
||||
@if (t.TemplateType == RecurringTemplateType.Bill)
|
||||
{
|
||||
<span>Bill</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Expense</span>
|
||||
}
|
||||
—
|
||||
@(t.IntervalCount == 1 ? t.Frequency.ToString() : $"Every {t.IntervalCount} × {t.Frequency}")
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Next Fire</span>
|
||||
<span class="mobile-card-value @(isOverdueRT ? "text-danger fw-semibold" : "")">
|
||||
@t.NextFireDate.ToString("MM/dd/yyyy")
|
||||
@if (isOverdueRT) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Occurrences</span>
|
||||
<span class="mobile-card-value">
|
||||
@t.OccurrenceCount
|
||||
@if (t.MaxOccurrences.HasValue) { <span class="text-muted"> / @t.MaxOccurrences</span> }
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(t.LastError))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Error</span>
|
||||
<span class="mobile-card-value text-danger small">@t.LastError</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post" style="display:inline;">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")">
|
||||
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
|
||||
</button>
|
||||
</form>
|
||||
@if (t.IsActive)
|
||||
{
|
||||
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Generate one occurrence now?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-lightning-charge"></i>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -65,6 +65,77 @@
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var note in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6><code>v@(note.Version)</code> — @note.Title</h6>
|
||||
<small>
|
||||
<span class="badge @TagBadge(note.Tag)">@note.Tag</span>
|
||||
|
||||
@if (note.IsPublished)
|
||||
{
|
||||
<span class="badge bg-success">Published</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Draft</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Released</span>
|
||||
<span class="mobile-card-value text-muted">@note.ReleasedAt.ToString("MM/dd/yyyy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Created By</span>
|
||||
<span class="mobile-card-value text-muted">@note.CreatedByUserName</span>
|
||||
</div>
|
||||
@if (note.Body.Length > 0)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Preview</span>
|
||||
<span class="mobile-card-value text-muted">@(note.Body.Length > 60 ? note.Body[..60] + "…" : note.Body)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Edit" asp-route-id="@note.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form asp-action="TogglePublish" asp-route-id="@note.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm @(note.IsPublished ? "btn-outline-warning" : "btn-outline-success")">
|
||||
<i class="bi @(note.IsPublished ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@note.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete v@(note.Version)?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-journal-x fs-1 d-block mb-2 opacity-25"></i>
|
||||
No release notes yet. <a asp-action="Create">Create the first one.</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -112,6 +112,101 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
|
||||
<i class="bi bi-building"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>
|
||||
@row.CompanyName
|
||||
@if (row.IsDeleted) { <span class="badge bg-secondary ms-1">Deleted</span> }
|
||||
</h6>
|
||||
<small>
|
||||
@if (row.SmsDisabledByAdmin)
|
||||
{
|
||||
<span class="text-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
|
||||
}
|
||||
else if (row.SmsEnabled)
|
||||
{
|
||||
<span class="text-success"><i class="bi bi-chat-dots me-1"></i>SMS Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">SMS Off</span>
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Terms</span>
|
||||
<span class="mobile-card-value">
|
||||
@{
|
||||
var dispAgreement = row.CurrentAgreement ?? row.LatestAgreement;
|
||||
}
|
||||
@if (row.CurrentAgreement != null)
|
||||
{
|
||||
<span class="badge bg-success">v@(row.CurrentAgreement.TermsVersion)</span>
|
||||
}
|
||||
else if (row.LatestAgreement != null)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Stale (v@(row.LatestAgreement.TermsVersion))</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-muted border">Never</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (dispAgreement != null)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Accepted By</span>
|
||||
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
|
||||
@dispAgreement.AgreedByUserName
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Accepted At</span>
|
||||
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
|
||||
@dispAgreement.AgreedAt.ToString("MM/dd/yy")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (row.AllAgreements.Count > 0)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">History</span>
|
||||
<span class="mobile-card-value">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#historyModal"
|
||||
data-company="@row.CompanyName"
|
||||
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
|
||||
a.TermsVersion,
|
||||
a.AgreedByUserName,
|
||||
a.AgreedByUserId,
|
||||
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
|
||||
IpAddress = a.IpAddress ?? "—",
|
||||
UserAgent = a.UserAgent ?? "—"
|
||||
}), new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase })"
|
||||
onclick="event.stopPropagation()">
|
||||
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -110,6 +110,64 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var row in Model.Rows)
|
||||
{
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Customers", new { id = row.CustomerId })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #ec4899 0%, #be185d 100%);">
|
||||
<i class="bi bi-phone-vibrate"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@row.CustomerName</h6>
|
||||
<small>@(row.MobilePhone ?? row.Phone ?? "No phone")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">SMS Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @row.StatusBadgeClass">@row.StatusLabel</span></span>
|
||||
</div>
|
||||
@if (row.ConsentedAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Consented</span>
|
||||
<span class="mobile-card-value">@row.ConsentedAt.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(row.ConsentMethod))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Method</span>
|
||||
<span class="mobile-card-value">@row.ConsentMethod</span>
|
||||
</div>
|
||||
}
|
||||
@if (row.OptedOutAt.HasValue)
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Opted Out</span>
|
||||
<span class="mobile-card-value text-danger">@row.OptedOutAt.Value.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-controller="Customers" asp-action="Details" asp-route-id="@row.CustomerId"
|
||||
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
|
||||
<i class="bi bi-person me-1"></i>Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!Model.Rows.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-phone-vibrate fs-1 d-block mb-2 opacity-25"></i>
|
||||
No customers match the current filter.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -84,6 +84,46 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var file in Model.Files.OrderBy(f => f.Status).ThenBy(f => f.RelativePath))
|
||||
{
|
||||
var fStatusBadge = file.Status switch
|
||||
{
|
||||
MigrationFileStatus.Migrated => "bg-success",
|
||||
MigrationFileStatus.Skipped => "bg-secondary",
|
||||
_ => "bg-danger"
|
||||
};
|
||||
var fStatusLabel = file.Status switch
|
||||
{
|
||||
MigrationFileStatus.Migrated => "Migrated",
|
||||
MigrationFileStatus.Skipped => "Already in Azure",
|
||||
_ => "Failed"
|
||||
};
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0369a1 0%, #075985 100%);">
|
||||
<i class="bi bi-file-earmark"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6 class="font-monospace" style="font-size:.75rem;">@file.RelativePath</h6>
|
||||
<small><span class="badge bg-light text-dark border">@file.Container</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Size</span>
|
||||
<span class="mobile-card-value text-muted">@FormatBytes(file.FileSize)</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @fStatusBadge">@fStatusLabel</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
|
||||
@@ -68,6 +68,64 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var vc in Model)
|
||||
{
|
||||
var (vcBadge, vcLabel) = vc.Status switch
|
||||
{
|
||||
VendorCreditStatus.Open => ("bg-success", "Open"),
|
||||
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partial"),
|
||||
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
|
||||
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
|
||||
_ => ("bg-secondary", vc.Status.ToString())
|
||||
};
|
||||
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = vc.Id })'">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
|
||||
<i class="bi bi-credit-card"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@vc.CreditNumber</h6>
|
||||
<small>@vc.Vendor?.CompanyName</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge @vcBadge">@vcLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Date</span>
|
||||
<span class="mobile-card-value">@vc.CreditDate.ToString("MM/dd/yy")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total</span>
|
||||
<span class="mobile-card-value">@vc.Total.ToString("C")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Remaining</span>
|
||||
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
|
||||
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "—")
|
||||
</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(vc.Memo))
|
||||
{
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Memo</span>
|
||||
<span class="mobile-card-value">@vc.Memo</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Details" asp-route-id="@vc.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
/* Hide desktop table view on mobile */
|
||||
.table-responsive {
|
||||
/* Hide desktop table only when a mobile card view sibling is present */
|
||||
.mobile-card-view ~ .table-responsive,
|
||||
.table-responsive:has(~ .mobile-card-view) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -2904,7 +2904,8 @@ async function runAutoPricing() {
|
||||
try {
|
||||
// Collect current form meta
|
||||
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
|
||||
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
|
||||
const _taxField = document.querySelector('[name="TaxPercent"]');
|
||||
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
|
||||
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
|
||||
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
|
||||
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
|
||||
|
||||
@@ -59,7 +59,8 @@ public class JobItemAssemblyServiceTests
|
||||
var pricing = new QuoteItemPricingResult
|
||||
{
|
||||
UnitPrice = 29.99m,
|
||||
TotalPrice = 59.98m
|
||||
TotalPrice = 59.98m,
|
||||
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
|
||||
};
|
||||
|
||||
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that quantities, prices, overrides, and charges move correctly through all three
|
||||
/// pricing stages: Quote → Job → Invoice. Each test targets one transition or cross-cutting concern.
|
||||
/// </summary>
|
||||
public class PricingStageFlowTests
|
||||
{
|
||||
// ─── Stage 1: QuotePricingAssemblyService.ApplyPricingSnapshot ───────────────
|
||||
|
||||
[Fact]
|
||||
public void ApplyPricingSnapshot_StoresAllNewBreakdownFields()
|
||||
{
|
||||
// FacilityOverheadCost, FacilityOverheadRatePerHour, PricingTierDiscount, QuoteDiscount,
|
||||
// and SubtotalAfterDiscount were added in a recent migration. Verify they are all stored.
|
||||
var service = CreateAssemblyService(CreateContext());
|
||||
var quote = new Quote();
|
||||
var pricing = new QuotePricingResult
|
||||
{
|
||||
FacilityOverheadCost = 12.50m,
|
||||
FacilityOverheadRatePerHour = 25m,
|
||||
PricingTierDiscountAmount = 5m,
|
||||
PricingTierDiscountPercent = 2m,
|
||||
QuoteDiscountAmount = 10m,
|
||||
QuoteDiscountPercent = 4m,
|
||||
DiscountAmount = 15m,
|
||||
DiscountPercent = 6m,
|
||||
SubtotalAfterDiscount = 235m,
|
||||
RushFee = 20m,
|
||||
TaxAmount = 23.5m,
|
||||
Total = 278.50m,
|
||||
SubtotalBeforeDiscount = 250m,
|
||||
ItemsSubtotal = 200m,
|
||||
OvenBatchCost = 18m,
|
||||
ShopSuppliesAmount = 8m,
|
||||
ShopSuppliesPercent = 4m
|
||||
};
|
||||
|
||||
service.ApplyPricingSnapshot(quote, pricing);
|
||||
|
||||
Assert.Equal(12.50m, quote.FacilityOverheadCost, precision: 2);
|
||||
Assert.Equal(25m, quote.FacilityOverheadRatePerHour, precision: 2);
|
||||
Assert.Equal(5m, quote.PricingTierDiscountAmount, precision: 2);
|
||||
Assert.Equal(2m, quote.PricingTierDiscountPercent, precision: 2);
|
||||
Assert.Equal(10m, quote.QuoteDiscountAmount, precision: 2);
|
||||
Assert.Equal(4m, quote.QuoteDiscountPercent, precision: 2);
|
||||
Assert.Equal(15m, quote.DiscountAmount, precision: 2);
|
||||
Assert.Equal(6m, quote.DiscountPercent, precision: 2);
|
||||
Assert.Equal(235m, quote.SubtotalAfterDiscount, precision: 2);
|
||||
Assert.Equal(20m, quote.RushFee, precision: 2);
|
||||
Assert.Equal(23.5m, quote.TaxAmount, precision: 2);
|
||||
Assert.Equal(278.50m, quote.Total, precision: 2);
|
||||
}
|
||||
|
||||
// ─── Stage 2: Quote → Job (QuotesController.UpdateQuoteStatus) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_PricingSnapshotCarriesAllCharges()
|
||||
{
|
||||
// Verifies that OvenBatchCost, FacilityOverheadCost, ShopSuppliesAmount, RushFee,
|
||||
// and all discount fields from the approved quote land in Job.PricingBreakdownJson.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
|
||||
{
|
||||
QuoteId = 1,
|
||||
StatusId = approvedStatusId
|
||||
});
|
||||
|
||||
Assert.IsType<JsonResult>(result);
|
||||
|
||||
var job = await context.Jobs.SingleAsync();
|
||||
Assert.NotNull(job.PricingBreakdownJson);
|
||||
|
||||
var breakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson!);
|
||||
Assert.NotNull(breakdown);
|
||||
|
||||
Assert.Equal(150m, breakdown.ItemsSubtotal, precision: 2);
|
||||
Assert.Equal(18m, breakdown.OvenBatchCost, precision: 2);
|
||||
Assert.Equal(12m, breakdown.FacilityOverheadCost, precision: 2);
|
||||
Assert.Equal(6m, breakdown.ShopSuppliesAmount, precision: 2);
|
||||
Assert.Equal(25m, breakdown.RushFee, precision: 2);
|
||||
Assert.Equal(15m, breakdown.DiscountAmount, precision: 2);
|
||||
Assert.Equal(211m, breakdown.Total, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_ItemPricesAndOverridesTransfer()
|
||||
{
|
||||
// Verifies that UnitPrice, TotalPrice, ManualUnitPrice, PowderCostOverride,
|
||||
// CatalogItemId, and Notes all survive the quote→job item conversion.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
|
||||
|
||||
var jobItem = await context.JobItems.SingleAsync();
|
||||
Assert.Equal(75m, jobItem.UnitPrice, precision: 2);
|
||||
Assert.Equal(150m, jobItem.TotalPrice, precision: 2);
|
||||
Assert.Equal(69m, jobItem.ManualUnitPrice);
|
||||
Assert.Equal(8.50m, jobItem.PowderCostOverride);
|
||||
Assert.Equal(99, jobItem.CatalogItemId);
|
||||
Assert.Equal("Handle carefully — thin walls", jobItem.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteToJob_CoatInventoryIdAndPowderToOrderTransfer()
|
||||
{
|
||||
// InventoryItemId on coats gates the powder charging logic in PricingCalculationService.
|
||||
// PowderToOrder is the purchase quantity — both must survive quote→job conversion.
|
||||
await using var context = CreateContext();
|
||||
SeedQuoteWithFullPricing(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateQuotesController(context);
|
||||
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
|
||||
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
|
||||
|
||||
var coat = await context.JobItemCoats.SingleAsync();
|
||||
Assert.Equal(50, coat.InventoryItemId);
|
||||
Assert.Equal(2.0m, coat.PowderToOrder);
|
||||
Assert.Equal(4.50m, coat.PowderCostPerLb);
|
||||
}
|
||||
|
||||
// ─── Stage 3: Job → Invoice (InvoicesController.Create GET with jobId) ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_ItemFieldsPopulateCorrectly()
|
||||
{
|
||||
// Notes and CatalogItemId on JobItem must reach InvoiceItem.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var item = dto.InvoiceItems.First(i => i.SourceJobItemId.HasValue);
|
||||
|
||||
Assert.Equal(3m, item.Quantity);
|
||||
Assert.Equal(45m, item.UnitPrice, precision: 2);
|
||||
Assert.Equal(135m, item.TotalPrice, precision: 2);
|
||||
Assert.Equal("Gloss Black", item.ColorName);
|
||||
Assert.Equal(99, item.CatalogItemId);
|
||||
Assert.Equal("Watch corners — mask before blasting", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_DirectJob_AddsOvenShopSuppliesRushFeeLines()
|
||||
{
|
||||
// A job created directly (no source quote) must invoice all three processing charges
|
||||
// separately, reading RushFee and FacilityOverheadCost from PricingBreakdownJson.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: false);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var descriptions = dto.InvoiceItems.Select(i => i.Description).ToList();
|
||||
|
||||
Assert.Contains("Oven Processing Fee", descriptions);
|
||||
Assert.Contains("Facility Overhead", descriptions);
|
||||
Assert.Contains("Shop Supplies (4%)", descriptions);
|
||||
Assert.Contains("Rush Fee", descriptions);
|
||||
|
||||
var oven = dto.InvoiceItems.Single(i => i.Description == "Oven Processing Fee");
|
||||
var overhead = dto.InvoiceItems.Single(i => i.Description == "Facility Overhead");
|
||||
var shop = dto.InvoiceItems.Single(i => i.Description == "Shop Supplies (4%)");
|
||||
var rush = dto.InvoiceItems.Single(i => i.Description == "Rush Fee");
|
||||
|
||||
Assert.Equal(18m, oven.TotalPrice, precision: 2);
|
||||
Assert.Equal(12m, overhead.TotalPrice, precision: 2);
|
||||
Assert.Equal(6m, shop.TotalPrice, precision: 2);
|
||||
Assert.Equal(25m, rush.TotalPrice, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_FromQuote_BundlesAllProcessingFeesIncludingFacilityOverhead()
|
||||
{
|
||||
// When a job came from a quote, all processing charges must be bundled as one line,
|
||||
// including FacilityOverheadCost which was previously missing.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: true);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
var processingLine = dto.InvoiceItems.SingleOrDefault(i => i.Description == "Oven & Shop Processing Fees");
|
||||
Assert.NotNull(processingLine);
|
||||
|
||||
// OvenBatchCost(18) + FacilityOverheadCost(12) + ShopSuppliesAmount(6) + RushFee(25) = 61
|
||||
Assert.Equal(61m, processingLine!.TotalPrice, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobToInvoice_TaxAndDiscountFromQuoteNotRecomputed()
|
||||
{
|
||||
// Invoice must carry the agreed quote TaxPercent and DiscountAmount,
|
||||
// not re-derive from current company defaults.
|
||||
await using var context = CreateContext();
|
||||
SeedJobForInvoicing(context, hasSourceQuote: true);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateInvoicesController(context);
|
||||
var result = await controller.Create(jobId: 1) as ViewResult;
|
||||
Assert.NotNull(result);
|
||||
|
||||
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
|
||||
Assert.Equal(8.5m, dto.TaxPercent, precision: 2);
|
||||
Assert.Equal(15m, dto.DiscountAmount, precision: 2);
|
||||
}
|
||||
|
||||
// ─── JobItemAssemblyService: Notes field ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromDto_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var dto = new CreateQuoteItemDto { Description = "Part", Notes = "Fragile — no drop" };
|
||||
var pricing = new QuoteItemPricingResult { UnitPrice = 10m, TotalPrice = 10m };
|
||||
|
||||
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Fragile — no drop", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var quoteItem = new QuoteItem { Description = "Part", Notes = "Do not sandblast" };
|
||||
|
||||
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Do not sandblast", item.Notes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromJobItem_PreservesNotes()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var source = new JobItem { Description = "Part", Notes = "Carry-over note", LaborCost = 0m };
|
||||
|
||||
var item = svc.CreateJobItem(source, jobId: 2, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("Carry-over note", item.Notes);
|
||||
}
|
||||
|
||||
// ─── LaborCost: must come from pricing engine, not a hardcoded multiplier ─────
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromDto_UsesLaborCostFromPricingResult()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var dto = new CreateQuoteItemDto { Description = "Rail" };
|
||||
var pricing = new QuoteItemPricingResult { UnitPrice = 100m, TotalPrice = 200m, LaborCost = 55m };
|
||||
|
||||
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(55m, item.LaborCost, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateJobItem_FromQuoteItem_UsesStoredItemLaborCost()
|
||||
{
|
||||
var svc = new JobItemAssemblyService();
|
||||
var quoteItem = new QuoteItem
|
||||
{
|
||||
Description = "Rail",
|
||||
UnitPrice = 100m,
|
||||
TotalPrice = 200m,
|
||||
ItemLaborCost = 62m
|
||||
};
|
||||
|
||||
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(62m, item.LaborCost, precision: 2);
|
||||
}
|
||||
|
||||
// ─── Seed helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static void SeedQuoteWithFullPricing(ApplicationDbContext context)
|
||||
{
|
||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
|
||||
context.InventoryItems.Add(new InventoryItem
|
||||
{
|
||||
Id = 50, CompanyId = 1, SKU = "BLK-1", Name = "Gloss Black",
|
||||
ColorCode = "RAL9005", Finish = "Gloss", Category = "Powder", UnitOfMeasure = "lbs"
|
||||
});
|
||||
|
||||
context.QuoteStatusLookups.AddRange(
|
||||
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
|
||||
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
|
||||
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{ Id = 10, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" });
|
||||
context.JobPriorityLookups.AddRange(
|
||||
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
|
||||
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
|
||||
context.PrepServices.Add(new PrepService
|
||||
{ Id = 5, CompanyId = 1, ServiceName = "Sandblast", DisplayOrder = 1, IsActive = true });
|
||||
|
||||
context.Quotes.Add(new Quote
|
||||
{
|
||||
Id = 1, CompanyId = 1, QuoteNumber = "Q-2601-0001", CustomerId = 1, QuoteStatusId = 1,
|
||||
IsRushJob = true,
|
||||
ItemsSubtotal = 150m,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
DiscountAmount = 15m,
|
||||
DiscountPercent = 6m,
|
||||
SubtotalAfterDiscount = 196m,
|
||||
TaxPercent = 8.5m,
|
||||
TaxAmount = 16.66m,
|
||||
Total = 211m
|
||||
});
|
||||
|
||||
context.QuoteItems.Add(new QuoteItem
|
||||
{
|
||||
Id = 100, QuoteId = 1, CompanyId = 1,
|
||||
Description = "Powder coat rail",
|
||||
Quantity = 2m,
|
||||
SurfaceAreaSqFt = 20m,
|
||||
CatalogItemId = 99,
|
||||
IsSalesItem = false,
|
||||
ManualUnitPrice = 69m,
|
||||
PowderCostOverride = 8.50m,
|
||||
UnitPrice = 75m,
|
||||
TotalPrice = 150m,
|
||||
ItemLaborCost = 40m,
|
||||
Notes = "Handle carefully — thin walls",
|
||||
IncludePrepCost = true,
|
||||
EstimatedMinutes = 30
|
||||
});
|
||||
|
||||
context.QuoteItemCoats.Add(new QuoteItemCoat
|
||||
{
|
||||
Id = 101, QuoteItemId = 100, CompanyId = 1,
|
||||
CoatName = "Base Coat", Sequence = 1,
|
||||
InventoryItemId = 50,
|
||||
ColorName = "Old Name",
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
PowderCostPerLb = 4.50m,
|
||||
PowderToOrder = 2.0m
|
||||
});
|
||||
|
||||
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
|
||||
{ Id = 102, QuoteItemId = 100, CompanyId = 1, PrepServiceId = 5, EstimatedMinutes = 10 });
|
||||
}
|
||||
|
||||
private static void SeedJobForInvoicing(ApplicationDbContext context, bool hasSourceQuote)
|
||||
{
|
||||
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
|
||||
|
||||
context.JobStatusLookups.Add(new JobStatusLookup
|
||||
{ Id = 1, CompanyId = 1, StatusCode = "COMPLETED", DisplayName = "Completed" });
|
||||
context.JobPriorityLookups.Add(new JobPriorityLookup
|
||||
{ Id = 1, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" });
|
||||
|
||||
// Serialized breakdown carrying FacilityOverheadCost and RushFee
|
||||
var breakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
ItemsSubtotal = 135m,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
TaxPercent = 8.5m,
|
||||
Total = 211m
|
||||
};
|
||||
|
||||
Quote? quote = null;
|
||||
if (hasSourceQuote)
|
||||
{
|
||||
quote = new Quote
|
||||
{
|
||||
Id = 1, CompanyId = 1, QuoteNumber = "Q-TEST", CustomerId = 1,
|
||||
QuoteStatusId = 1,
|
||||
OvenBatchCost = 18m,
|
||||
FacilityOverheadCost = 12m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
RushFee = 25m,
|
||||
DiscountAmount = 15m,
|
||||
TaxPercent = 8.5m,
|
||||
Total = 211m
|
||||
};
|
||||
context.QuoteStatusLookups.Add(new QuoteStatusLookup
|
||||
{ Id = 1, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
|
||||
context.Quotes.Add(quote);
|
||||
}
|
||||
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = 1, CompanyId = 1, JobNumber = "JOB-TEST", CustomerId = 1,
|
||||
Description = "Test job",
|
||||
JobStatusId = 1,
|
||||
JobPriorityId = 1,
|
||||
QuoteId = hasSourceQuote ? 1 : null,
|
||||
OvenBatchCost = 18m,
|
||||
ShopSuppliesAmount = 6m,
|
||||
ShopSuppliesPercent = 4m,
|
||||
IsRushJob = true,
|
||||
FinalPrice = 211m,
|
||||
PricingBreakdownJson = JsonSerializer.Serialize(breakdown)
|
||||
});
|
||||
|
||||
context.JobItems.Add(new JobItem
|
||||
{
|
||||
Id = 10, JobId = 1, CompanyId = 1,
|
||||
Description = "Powder coat wheel",
|
||||
Quantity = 3m,
|
||||
UnitPrice = 45m,
|
||||
TotalPrice = 135m,
|
||||
ColorName = "Gloss Black",
|
||||
CatalogItemId = 99,
|
||||
Notes = "Watch corners — mask before blasting",
|
||||
EstimatedMinutes = 20,
|
||||
LaborCost = 30m
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Controller / service factory helpers ────────────────────────────────────
|
||||
|
||||
private static QuotePricingAssemblyService CreateAssemblyService(ApplicationDbContext context) =>
|
||||
new(new UnitOfWork(context),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
Mock.Of<IInventoryAiLookupService>(),
|
||||
Mock.Of<ILogger<QuotePricingAssemblyService>>());
|
||||
|
||||
private static QuotesController CreateQuotesController(ApplicationDbContext context)
|
||||
{
|
||||
var lookupCache = new Mock<ILookupCacheService>();
|
||||
lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny<int>()))
|
||||
.ReturnsAsync(() => context.QuoteStatusLookups.ToList());
|
||||
|
||||
return new QuotesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
Mock.Of<IPricingCalculationService>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<QuotesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<IMeasurementConversionService>(),
|
||||
lookupCache.Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
new JobItemAssemblyService(),
|
||||
Mock.Of<IQuotePricingAssemblyService>(),
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<IPlatformSettingsService>(),
|
||||
Mock.Of<IQuotePhotoService>(),
|
||||
Mock.Of<IAiQuoteService>(),
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IJobPhotoService>(),
|
||||
Mock.Of<IAiUsageLogger>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
Mock.Of<IInventoryAiLookupService>());
|
||||
}
|
||||
|
||||
private static InvoicesController CreateInvoicesController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new InvoicesController(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<AutoMapper.IMapper>(),
|
||||
CreateUserManager().Object,
|
||||
Mock.Of<ILogger<InvoicesController>>(),
|
||||
Mock.Of<IPdfService>(),
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<IAccountBalanceService>(),
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = principal }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
var mgr = new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
mgr.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
|
||||
.ReturnsAsync(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
CompanyId = 1,
|
||||
UserName = "testuser",
|
||||
Email = "test@test.com"
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
private static Mock<ITenantContext> CreateTenantContext()
|
||||
{
|
||||
var tc = new Mock<ITenantContext>();
|
||||
tc.Setup(x => x.GetCurrentCompanyId()).Returns(1);
|
||||
tc.Setup(x => x.IsSuperAdmin()).Returns(true);
|
||||
tc.Setup(x => x.IsPlatformAdmin()).Returns(true);
|
||||
return tc;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user