Compare commits

...

10 Commits

Author SHA1 Message Date
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
spouliot 4a7087cc0c Fix NoExtraLayerCharge dropped in DeleteItem pricing recalculation
After deleting a job item, the remaining-items DTO projection was missing
NoExtraLayerCharge, causing PricingCalculationService to treat all coats as
extra-charge when recalculating the job total post-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:37:29 -04:00
spouliot 59b152c89f Fix noExtraLayerCharge missing from Job Details wizard item projection
WizardExistingItems coat serialization in Details GET omitted noExtraLayerCharge,
so editing a line item from the Details page always lost the no-charge flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:36:04 -04:00
spouliot 441898b52f Fix NoExtraLayerCharge not persisting on quotes and job EditItems reload
- QuotePricingAssemblyService.BuildQuoteItemCoat: map NoExtraLayerCharge from
  CreateQuoteItemCoatDto to QuoteItemCoat on every quote save (was always omitted)
- JobsController.EditItems GET: include NoExtraLayerCharge in coat mapping when
  reloading existing items for the wizard (was dropped, causing revert on second edit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:33:50 -04:00
spouliot 3e30397302 Sync master back to dev (IF EXISTS migration hotfix) 2026-05-19 18:24:39 -04:00
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:30 -04:00
spouliot 3f9ac27afa Merge dev into master for release v2026.05.19 2026-05-19 16:37:26 -04:00
spouliot df504674e9 Add oven/batch settings to job create and edit forms
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.

Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.

ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.

Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:54 -04:00
spouliot 07796b05c8 Clear ReminderSentAt when appointment is rescheduled
Edit POST now detects if ScheduledStartTime changed (via previousStart
comparison after AutoMapper merge) and nulls ReminderSentAt so the
background service will fire the reminder again at the new time.
Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since
rescheduling is its only purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:03:58 -04:00
spouliot 2bf8871892 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>
2026-05-19 15:48:16 -04:00
31 changed files with 39290 additions and 229 deletions
@@ -137,6 +137,13 @@ public class CreateJobDto
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -208,6 +215,16 @@ public class UpdateJobDto
[Display(Name = "Assigned Worker")]
public string? AssignedUserId { get; set; }
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -381,6 +398,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>
@@ -264,6 +264,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
@@ -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,217 @@
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)
{
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
DROP TABLE [ShopWorkerRoleCosts];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
DROP TABLE [ShopWorkers];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
");
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.");
}
}
}
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
return View(dto);
}
// Map changes
// Map changes — capture old start before overwrite so we can detect a reschedule.
var previousStart = appointment.ScheduledStartTime;
_mapper.Map(dto, appointment);
// If the appointment was rescheduled, clear the reminder stamp so the background
// service will fire again at the new time.
if (appointment.ScheduledStartTime != previousStart)
appointment.ReminderSentAt = null;
// Update
await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync();
@@ -725,6 +731,8 @@ public class AppointmentsController : Controller
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
appointment.ScheduledStartTime = start;
appointment.ScheduledEndTime = end;
// Drag-drop always changes the time — reset so the reminder fires at the new time.
appointment.ReminderSentAt = null;
await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync();
@@ -477,6 +477,7 @@ public class JobsController : Controller
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
noExtraLayerCharge = c.NoExtraLayerCharge,
notes = c.Notes
}),
prepServices = ji.PrepServices.Select(ps => new {
@@ -1078,6 +1079,8 @@ public class JobsController : Controller
QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId,
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
OvenCycleMinutes = dto.OvenCycleMinutes,
Description = dto.Description,
JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1,
@@ -1149,7 +1152,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
@@ -1217,6 +1220,9 @@ public class JobsController : Controller
CustomerId = job.CustomerId,
QuoteId = job.QuoteId,
AssignedUserId = job.AssignedUserId,
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
Description = job.Description,
JobStatusId = job.JobStatusId,
JobPriorityId = job.JobPriorityId,
@@ -1261,6 +1267,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -1391,6 +1398,9 @@ public class JobsController : Controller
job.CustomerId = dto.CustomerId;
job.QuoteId = dto.QuoteId;
job.Description = dto.Description;
job.OvenCostId = dto.OvenCostId;
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
job.OvenCycleMinutes = dto.OvenCycleMinutes;
await RecordStatusChangeAsync(job, dto.JobStatusId);
job.JobStatusId = dto.JobStatusId;
job.JobPriorityId = dto.JobPriorityId;
@@ -1617,7 +1627,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
@@ -1818,6 +1828,7 @@ public class JobsController : Controller
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -2936,6 +2947,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -3116,7 +3128,8 @@ public class JobsController : Controller
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb
PowderCostPerLb = c.PowderCostPerLb,
NoExtraLayerCharge = c.NoExtraLayerCharge
}).ToList()
}).ToList();
+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)
+46 -2
View File
@@ -221,6 +221,49 @@
</div>
</div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4">
<div class="card-header">
@@ -370,7 +413,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,
@@ -387,7 +431,7 @@
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null,
"ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -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>
+46 -2
View File
@@ -190,6 +190,49 @@
</div>
</div>
<!-- Oven & Batch Settings -->
<div class="card mb-4">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<h5 class="mb-0"><i class="bi bi-thermometer-half me-2"></i>Oven &amp; Batch Settings</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the job level, not per item. Estimate how many oven loads the full job will fill &amp;mdash; for example, if you have 20 small parts and your oven fits 10, that&amp;#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&amp;#39;s hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</div>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Estimate how many oven loads the complete job will require. The oven cycle cost is added once at the job level, not per item.</p>
<div class="row g-3 align-items-end">
@if (ViewBag.OvenCosts != null && ((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts).Count > 1)
{
<div class="col-md-4">
<label asp-for="OvenCostId" class="form-label fw-semibold"><i class="bi bi-thermometer-half me-1"></i>Oven</label>
<select asp-for="OvenCostId" asp-items="@((List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.OvenCosts)"
class="form-select" onchange="scheduleAutoPricing()"></select>
</div>
}
<div class="col-sm-3 col-md-2">
<label asp-for="OvenBatches" class="form-label fw-semibold">Batches</label>
<input asp-for="OvenBatches" class="form-control" type="number" min="1"
value="@(Model.OvenBatches > 0 ? Model.OvenBatches : 1)"
id="OvenBatches" onchange="scheduleAutoPricing()" />
</div>
<div class="col-sm-4 col-md-3">
<label asp-for="OvenCycleMinutes" class="form-label fw-semibold">
Cycle Time per Batch (min)
<small class="text-muted fw-normal">default: @(ViewBag.DefaultOvenCycleMinutes ?? 45)</small>
</label>
<input asp-for="OvenCycleMinutes" class="form-control" type="number" min="1"
id="OvenCycleMinutes" placeholder="@(ViewBag.DefaultOvenCycleMinutes ?? 45)"
onchange="scheduleAutoPricing()" />
</div>
</div>
</div>
</div>
<!-- Pricing Options (Rush / Discount) -->
<div class="card mb-4">
<div class="card-header">
@@ -357,7 +400,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,
@@ -374,7 +418,7 @@
"discountType": @Json.Serialize(Model.DiscountType),
"discountValue": @Model.DiscountValue,
"isRushJob": @Json.Serialize(Model.IsRushJob),
"ovenCostId": null,
"ovenCostId": @Json.Serialize(Model.OvenCostId),
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
@@ -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,