Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button

- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 15:48:16 -04:00
parent dd4785b048
commit 2bf8871892
28 changed files with 39174 additions and 223 deletions
@@ -381,6 +381,7 @@ public class JobItemCoatDto
public decimal? PowderCostPerLb { get; set; }
public decimal? PowderToOrder { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -91,4 +91,11 @@ public interface INotificationService
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
/// </summary>
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
/// <summary>
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// when the reminder window opens. In-app bell notification is handled by the caller.
/// </summary>
Task NotifyAppointmentReminderAsync(Appointment appointment);
}
@@ -85,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -192,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -289,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -375,6 +378,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -493,6 +497,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
public bool NoExtraLayerCharge { get; init; }
}
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
/// </summary>
public int ReminderMinutesBefore { get; set; } = 30;
/// <summary>
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
/// The background service uses this as a deduplication guard to prevent double-sending.
/// </summary>
public DateTime? ReminderSentAt { get; set; }
// Navigation Properties
public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; }
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -20,5 +20,7 @@ public enum NotificationType
SmsInboundStop = 12,
SmsInboundHelp = 13,
AdminEmail = 14,
SmsInboundStart = 15
SmsInboundStart = 15,
AppointmentReminder = 17,
AppointmentReminderStaff = 18
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
// These UpdateData calls were generated from an existing live database.
// On a fresh install the PricingTiers table and its seed rows may not exist yet
// (seeding is manual via Platform Management → Seed Data), so guard each update.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
END
");
}
/// <inheritdoc />
@@ -0,0 +1,232 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAppointmentReminderSentAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs");
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropTable(
name: "ShopWorkerRoleCosts");
migrationBuilder.DropTable(
name: "ShopWorkers");
migrationBuilder.DropIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "Jobs");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "Appointments",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "Appointments");
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "MaintenanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "ShopWorkerRoleCosts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ShopWorkers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
table.ForeignKey(
name: "FK_ShopWorkers_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
migrationBuilder.CreateIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
table: "ShopWorkerRoleCosts",
columns: new[] { "CompanyId", "Role" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShopWorkers_CompanyId",
table: "ShopWorkers",
column: "CompanyId");
migrationBuilder.AddForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNoExtraLayerChargeToCoats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "JobItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats");
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "JobItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
}
}
}
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("ReminderMinutesBefore")
.HasColumnType("int");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledEndTime")
.HasColumnType("datetime2");
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)");
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("CompanyId", "CustomerId")
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobItemId")
.HasColumnType("int");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage")
.HasColumnType("nvarchar(max)");
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("JobId");
b.HasIndex("ShopWorkerId");
b.ToTable("JobTimeEntries");
});
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("Status");
b.HasIndex("CompanyId", "ScheduledDate")
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("ReworkRecords");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("ShopWorkers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("HourlyRate")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId", "Role")
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
b.ToTable("ShopWorkerRoleCosts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
{
b.Property<long>("Id")
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedJobs")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Customer");
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId");
b.Navigation("Job");
b.Navigation("Worker");
});
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany()
.HasForeignKey("RecurrenceParentId");
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedMaintenanceTasks")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Equipment");
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("ReworkJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
.WithMany("ShopWorkers")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
b.Navigation("ShopWorkers");
b.Navigation("Users");
b.Navigation("Vendors");
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Navigation("AssignedJobs");
b.Navigation("AssignedMaintenanceTasks");
b.Navigation("TimeEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Navigation("BillPayments");
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
};
}
/// <summary>
/// Sends appointment reminder emails when an appointment's reminder window opens.
/// Two emails are dispatched independently:
/// <list type="bullet">
/// <item>Customer email — sent when a customer is linked, has an email address, and has
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
/// the appointment). This fires regardless of whether a customer is linked.</item>
/// </list>
/// Called exclusively by
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
/// </summary>
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
{
try
{
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
var baseUrl = await GetBaseUrlAsync();
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
: string.Empty;
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
var appointmentTime = appointment.IsAllDay
? "All Day"
: appointment.ScheduledStartTime.ToString("h:mm tt");
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
// ── Customer email ────────────────────────────────────────────────
if (appointment.CustomerId != null)
{
var customer = appointment.Customer
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
if (customer != null)
{
var customerName = GetCustomerDisplayName(customer);
var reminderEmails = ParseEmailList(customer.Email);
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
{
if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
customerId: customer.Id));
}
}
else
{
var customerValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["locationLine"] = locationLine
};
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminder,
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = custLog,
Subject = custSubject,
Message = custPlainText,
ErrorMessage = custErr,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = appointment.CompanyId
});
}
}
}
// ── Staff email ───────────────────────────────────────────────────
// Send to whoever created the appointment so they get an out-of-app reminder.
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
{
// Look up the user's display name from Identity if available.
var staffUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
? staffUser.FullName
: appointment.CreatedBy;
// Include a customer line only when a customer is linked.
var customerLine = appointment.Customer != null
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
: string.Empty;
var staffValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["staffName"] = staffName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["customerLine"] = customerLine,
["locationLine"] = locationLine
};
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
var staffPlainText = StripHtml(staffHtml);
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminderStaff,
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = staffName,
Recipient = staffLog,
Subject = staffSubject,
Message = staffPlainText,
ErrorMessage = staffErr,
SentAt = DateTime.UtcNow,
CompanyId = appointment.CompanyId
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
}
}
// -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template)
// -----------------------------------------------------------------------
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>&#8212; {{companyName}}</p>"
),
};
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
/// an email to the linked customer plus an in-app bell notification to company staff.
///
/// Deduplication strategy: after selecting candidates the service immediately stamps
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
/// than sending a stale reminder.
/// </summary>
public class AppointmentReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Appointments whose scheduled start is more than this far in the past are ignored even
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
/// </summary>
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
public AppointmentReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<AppointmentReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
/// the service shuts down promptly when the application stops.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AppointmentReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(PollingInterval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
}
/// <summary>
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
/// the DbContext change tracker is clean each time.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
var now = DateTime.Now;
var lookback = now - MaxLookback;
// Find appointments where:
// - Reminder is enabled and has not been sent yet
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
var candidates = await db.Appointments
.IgnoreQueryFilters()
.Include(a => a.Customer)
.Include(a => a.AppointmentStatus)
.Where(a =>
!a.IsDeleted &&
a.IsReminderEnabled &&
a.ReminderSentAt == null &&
a.ScheduledStartTime > lookback &&
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
!a.AppointmentStatus.IsTerminalStatus)
.ToListAsync(ct);
if (candidates.Count == 0) return;
_logger.LogInformation(
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
candidates.Count);
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
var stampedAt = now;
foreach (var appt in candidates)
appt.ReminderSentAt = stampedAt;
await db.SaveChangesAsync(ct);
// Now send notifications. Failures here don't roll back the stamp because we'd
// rather skip one reminder than spam a customer on every restart.
foreach (var appt in candidates)
{
if (ct.IsCancellationRequested) break;
try
{
// Email to linked customer (no-ops internally if customer has opted out)
await notificationService.NotifyAppointmentReminderAsync(appt);
// In-app bell notification for company staff
var when = appt.IsAllDay
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
await inAppService.CreateAsync(
companyId: appt.CompanyId,
title: $"Appointment Reminder: {appt.Title}",
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
notificationType: "AppointmentReminder",
link: $"/Appointments/Details/{appt.Id}",
customerId: appt.CustomerId);
_logger.LogInformation(
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
appt.AppointmentNumber, appt.Id, appt.CompanyId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
}
}
}
+1
View File
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -185,12 +185,13 @@
<div class="mobile-card-view">
@if (!Model.Items.Any())
{
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No customers found</h5>
<p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<p class="text-muted mb-4">@(isMobileCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
<i class="bi bi-plus-circle me-2"></i>@(isMobileCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
</a>
</div>
}
@@ -869,42 +869,45 @@
</div>
</div>
@if (showSendModal)
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="sendChannelModalLabel">
<i class="bi bi-send text-primary me-2"></i>Send Invoice
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-2">
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
<i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
</button>
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
<i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
</button>
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
<i class="bi bi-send me-2"></i>Both Email &amp; SMS
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
</button>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
}
@if (showSendModal)
{
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
where the customer's email was added after an SMS-only initial send. -->
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="sendChannelModalLabel">
<i class="bi bi-send text-primary me-2"></i>Send Invoice
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body pt-2">
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
<i class="bi bi-envelope me-2"></i>Email only
<small class="d-block text-muted ms-4">PDF attached &middot; @Model.CustomerEmail</small>
</button>
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
<i class="bi bi-phone me-2"></i>SMS only
<small class="d-block text-muted ms-4">View link &middot; @smsPhone</small>
</button>
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
<i class="bi bi-send me-2"></i>Both Email &amp; SMS
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
</button>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
}
</div>
}
@if (canPay)
@@ -370,7 +370,8 @@
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
@@ -653,7 +653,10 @@
<span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }</small>
<small class="d-block">
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
</small>
}
</span>
</div>
+2 -1
View File
@@ -357,7 +357,8 @@
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
@@ -153,7 +153,8 @@
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
}),
prepServices = item.PrepServices.Select(ps => new {
prepServiceId = ps.PrepServiceId,
@@ -479,7 +479,8 @@
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
})
})))
</script>
@@ -525,7 +525,8 @@
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
notes = c.Notes
notes = c.Notes,
noExtraLayerCharge = c.NoExtraLayerCharge
})
})))
</script>
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
const ownerForm = hfc?.closest('form');
if (ownerForm) {
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
// Save scroll position before the form causes a full-page reload so we can
// restore it after the server redirects back to this page. Key is path-specific
// so navigating away and back doesn't restore a stale position.
const scrollKey = 'wizardScrollY:' + location.pathname;
ownerForm.addEventListener('submit', () => {
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
}, { capture: true });
// Restore on load — fire after layout is painted so scrollTo lands correctly.
const savedY = sessionStorage.getItem(scrollKey);
if (savedY !== null) {
sessionStorage.removeItem(scrollKey);
requestAnimationFrame(() => window.scrollTo({ top: parseInt(savedY, 10), behavior: 'instant' }));
}
}
// Close any open powder combobox or catalog lookup dropdown when clicking outside it
@@ -2731,8 +2746,9 @@ function buildCardHtml(item, i) {
const orderBadge = (!c.inventoryItemId && c.powderToOrder)
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
: '';
const coatNotes = c.notes ? `<span class="fst-italic ms-1 opacity-75">— ${escHtml(c.notes)}</span>` : '';
return `<div style="font-size:.8rem;" class="text-muted mt-1">
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge}
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` ${color}${code}` : ''}${orderBadge}${coatNotes}
</div>`;
}).join('');
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
transferEfficiency: c.transferEfficiency || 65,
powderCostPerLb: c.powderCostPerLb || null,
noExtraLayerCharge: false
noExtraLayerCharge: !!c.noExtraLayerCharge
})),
prepServices: (ti.prepServices || []).map(p => ({
prepServiceId: p.prepServiceId,