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:
@@ -381,6 +381,7 @@ public class JobItemCoatDto
|
|||||||
public decimal? PowderCostPerLb { get; set; }
|
public decimal? PowderCostPerLb { get; set; }
|
||||||
public decimal? PowderToOrder { get; set; }
|
public decimal? PowderToOrder { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
|
|||||||
public decimal CoatMaterialCost { get; set; }
|
public decimal CoatMaterialCost { get; set; }
|
||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { get; set; }
|
public decimal CoatTotalCost { get; set; }
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { 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.
|
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
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,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -192,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -289,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -375,6 +378,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
PowderCostPerLb = seed.PowderCostPerLb,
|
PowderCostPerLb = seed.PowderCostPerLb,
|
||||||
PowderToOrder = seed.PowderToOrder,
|
PowderToOrder = seed.PowderToOrder,
|
||||||
Notes = seed.Notes,
|
Notes = seed.Notes,
|
||||||
|
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
@@ -493,6 +497,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public decimal? PowderCostPerLb { get; init; }
|
public decimal? PowderCostPerLb { get; init; }
|
||||||
public decimal? PowderToOrder { get; init; }
|
public decimal? PowderToOrder { get; init; }
|
||||||
public string? Notes { 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>
|
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReminderMinutesBefore { get; set; } = 30;
|
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
|
// Navigation Properties
|
||||||
public virtual Customer? Customer { get; set; }
|
public virtual Customer? Customer { get; set; }
|
||||||
public virtual Job? Job { get; set; }
|
public virtual Job? Job { get; set; }
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
|||||||
public string? PowderReceivedByUserId { get; set; }
|
public string? PowderReceivedByUserId { get; set; }
|
||||||
public decimal? PowderReceivedLbs { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
|||||||
SmsInboundStop = 12,
|
SmsInboundStop = 12,
|
||||||
SmsInboundHelp = 13,
|
SmsInboundHelp = 13,
|
||||||
AdminEmail = 14,
|
AdminEmail = 14,
|
||||||
SmsInboundStart = 15
|
SmsInboundStart = 15,
|
||||||
|
AppointmentReminder = 17,
|
||||||
|
AppointmentReminderStaff = 18
|
||||||
}
|
}
|
||||||
|
|||||||
+10633
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 />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.UpdateData(
|
// These UpdateData calls were generated from an existing live database.
|
||||||
table: "PricingTiers",
|
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||||
keyColumn: "Id",
|
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||||
keyValue: 1,
|
migrationBuilder.Sql(@"
|
||||||
column: "CreatedAt",
|
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||||
migrationBuilder.UpdateData(
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||||
table: "PricingTiers",
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||||
keyColumn: "Id",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||||
keyValue: 2,
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||||
column: "CreatedAt",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
END
|
||||||
|
");
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
Generated
+10633
File diff suppressed because it is too large
Load Diff
+232
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10639
File diff suppressed because it is too large
Load Diff
+83
@@ -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")
|
b.Property<int>("ReminderMinutesBefore")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReminderSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<DateTime>("ScheduledEndTime")
|
b.Property<DateTime>("ScheduledEndTime")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ShopSuppliesPercent")
|
b.Property<decimal>("ShopSuppliesPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("SpecialInstructions")
|
b.Property<string>("SpecialInstructions")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "CustomerId")
|
b.HasIndex("CompanyId", "CustomerId")
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||||||
|
|
||||||
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobItemId")
|
b.Property<int>("JobItemId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("Stage")
|
b.Property<string>("Stage")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("JobId");
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.ToTable("JobTimeEntries");
|
b.ToTable("JobTimeEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("ScheduledDate")
|
b.Property<DateTime>("ScheduledDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "ScheduledDate")
|
b.HasIndex("CompanyId", "ScheduledDate")
|
||||||
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("ReworkRecords");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedJobs")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
|
||||||
.WithMany("TimeEntries")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
|
|
||||||
b.Navigation("Worker");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||||
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("RecurrenceParentId");
|
.HasForeignKey("RecurrenceParentId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedMaintenanceTasks")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Equipment");
|
b.Navigation("Equipment");
|
||||||
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("ReworkJob");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
|
|
||||||
b.Navigation("ShopWorkers");
|
|
||||||
|
|
||||||
b.Navigation("Users");
|
b.Navigation("Users");
|
||||||
|
|
||||||
b.Navigation("Vendors");
|
b.Navigation("Vendors");
|
||||||
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Quotes");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillPayments");
|
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)
|
// 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)",
|
"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>"
|
"<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 & 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 & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>— {{companyName}}</p>"
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
|||||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||||
|
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||||
|
|||||||
@@ -185,12 +185,13 @@
|
|||||||
<div class="mobile-card-view">
|
<div class="mobile-card-view">
|
||||||
@if (!Model.Items.Any())
|
@if (!Model.Items.Any())
|
||||||
{
|
{
|
||||||
|
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
<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">
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,9 +869,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@if (showSendModal)
|
@if (showSendModal)
|
||||||
{
|
{
|
||||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
<!-- 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 fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -886,11 +890,11 @@
|
|||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
<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
|
<i class="bi bi-envelope me-2"></i>Email only
|
||||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
<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
|
<i class="bi bi-phone me-2"></i>SMS only
|
||||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
<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 & SMS
|
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||||
@@ -905,7 +909,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@if (canPay)
|
@if (canPay)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -370,7 +370,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
|
|||||||
@@ -653,7 +653,10 @@
|
|||||||
<span class="mobile-card-value">
|
<span class="mobile-card-value">
|
||||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||||
{
|
{
|
||||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
<small class="d-block">
|
||||||
|
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }
|
||||||
|
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
|
||||||
|
</small>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -357,7 +357,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
|
|||||||
@@ -153,7 +153,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}),
|
}),
|
||||||
prepServices = item.PrepServices.Select(ps => new {
|
prepServices = item.PrepServices.Select(ps => new {
|
||||||
prepServiceId = ps.PrepServiceId,
|
prepServiceId = ps.PrepServiceId,
|
||||||
|
|||||||
@@ -479,7 +479,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
})
|
})
|
||||||
})))
|
})))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -525,7 +525,8 @@
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
notes = c.Notes
|
notes = c.Notes,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
})
|
})
|
||||||
})))
|
})))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const ownerForm = hfc?.closest('form');
|
const ownerForm = hfc?.closest('form');
|
||||||
if (ownerForm) {
|
if (ownerForm) {
|
||||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
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
|
// 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)
|
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>`
|
? ` <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">
|
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>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
|
|||||||
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
||||||
transferEfficiency: c.transferEfficiency || 65,
|
transferEfficiency: c.transferEfficiency || 65,
|
||||||
powderCostPerLb: c.powderCostPerLb || null,
|
powderCostPerLb: c.powderCostPerLb || null,
|
||||||
noExtraLayerCharge: false
|
noExtraLayerCharge: !!c.noExtraLayerCharge
|
||||||
})),
|
})),
|
||||||
prepServices: (ti.prepServices || []).map(p => ({
|
prepServices: (ti.prepServices || []).map(p => ({
|
||||||
prepServiceId: p.prepServiceId,
|
prepServiceId: p.prepServiceId,
|
||||||
|
|||||||
Reference in New Issue
Block a user