Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e30397302 | |||
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 |
@@ -137,6 +137,13 @@ public class CreateJobDto
|
|||||||
[Display(Name = "Oven")]
|
[Display(Name = "Oven")]
|
||||||
public int? OvenCostId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
|||||||
[Display(Name = "Assigned Worker")]
|
[Display(Name = "Assigned Worker")]
|
||||||
public string? AssignedUserId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -381,6 +398,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
+217
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
|
|||||||
return View(dto);
|
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);
|
_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
|
// Update
|
||||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -725,6 +731,8 @@ public class AppointmentsController : Controller
|
|||||||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||||||
appointment.ScheduledStartTime = start;
|
appointment.ScheduledStartTime = start;
|
||||||
appointment.ScheduledEndTime = end;
|
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.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|||||||
@@ -1078,6 +1078,8 @@ public class JobsController : Controller
|
|||||||
QuoteId = dto.QuoteId,
|
QuoteId = dto.QuoteId,
|
||||||
AssignedUserId = dto.AssignedUserId,
|
AssignedUserId = dto.AssignedUserId,
|
||||||
OvenCostId = dto.OvenCostId,
|
OvenCostId = dto.OvenCostId,
|
||||||
|
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = dto.OvenCycleMinutes,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
JobPriorityId = dto.JobPriorityId,
|
JobPriorityId = dto.JobPriorityId,
|
||||||
JobStatusId = pendingStatus?.Id ?? 1,
|
JobStatusId = pendingStatus?.Id ?? 1,
|
||||||
@@ -1149,7 +1151,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
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.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
@@ -1217,6 +1219,9 @@ public class JobsController : Controller
|
|||||||
CustomerId = job.CustomerId,
|
CustomerId = job.CustomerId,
|
||||||
QuoteId = job.QuoteId,
|
QuoteId = job.QuoteId,
|
||||||
AssignedUserId = job.AssignedUserId,
|
AssignedUserId = job.AssignedUserId,
|
||||||
|
OvenCostId = job.OvenCostId,
|
||||||
|
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||||
Description = job.Description,
|
Description = job.Description,
|
||||||
JobStatusId = job.JobStatusId,
|
JobStatusId = job.JobStatusId,
|
||||||
JobPriorityId = job.JobPriorityId,
|
JobPriorityId = job.JobPriorityId,
|
||||||
@@ -1261,6 +1266,7 @@ public class JobsController : Controller
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
Notes = c.Notes
|
Notes = c.Notes
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||||
@@ -1391,6 +1397,9 @@ public class JobsController : Controller
|
|||||||
job.CustomerId = dto.CustomerId;
|
job.CustomerId = dto.CustomerId;
|
||||||
job.QuoteId = dto.QuoteId;
|
job.QuoteId = dto.QuoteId;
|
||||||
job.Description = dto.Description;
|
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);
|
await RecordStatusChangeAsync(job, dto.JobStatusId);
|
||||||
job.JobStatusId = dto.JobStatusId;
|
job.JobStatusId = dto.JobStatusId;
|
||||||
job.JobPriorityId = dto.JobPriorityId;
|
job.JobPriorityId = dto.JobPriorityId;
|
||||||
@@ -1617,7 +1626,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
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.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
@@ -1818,6 +1827,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||||
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
||||||
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
||||||
|
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
|
||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
|
|||||||
@@ -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,42 +869,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showSendModal)
|
}
|
||||||
{
|
|
||||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
@if (showSendModal)
|
||||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
{
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
|
||||||
<div class="modal-content">
|
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
|
||||||
<div class="modal-header border-0 pb-0">
|
where the customer's email was added after an SMS-only initial send. -->
|
||||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
</h5>
|
<div class="modal-content">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<div class="modal-header border-0 pb-0">
|
||||||
</div>
|
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||||
<div class="modal-body pt-2">
|
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
</h5>
|
||||||
<div class="d-grid gap-2">
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
</div>
|
||||||
<i class="bi bi-envelope me-2"></i>Email only
|
<div class="modal-body pt-2">
|
||||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||||
</button>
|
<div class="d-grid gap-2">
|
||||||
<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(true, false)" data-bs-dismiss="modal">
|
||||||
<i class="bi bi-phone me-2"></i>SMS only
|
<i class="bi bi-envelope me-2"></i>Email only
|
||||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</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-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
<i class="bi bi-phone me-2"></i>SMS only
|
||||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||||
</div>
|
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||||
<div class="modal-footer border-0 pt-0">
|
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canPay)
|
@if (canPay)
|
||||||
|
|||||||
@@ -221,6 +221,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 & 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 & 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 &mdash; for example, if you have 20 small parts and your oven fits 10, that&#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&#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) -->
|
<!-- Pricing Options (Rush / Discount) -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -370,7 +413,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,
|
||||||
@@ -387,7 +431,7 @@
|
|||||||
"discountType": @Json.Serialize(Model.DiscountType),
|
"discountType": @Json.Serialize(Model.DiscountType),
|
||||||
"discountValue": @Model.DiscountValue,
|
"discountValue": @Model.DiscountValue,
|
||||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||||
"ovenCostId": null,
|
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -190,6 +190,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 & 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 & 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 &mdash; for example, if you have 20 small parts and your oven fits 10, that&#39;s 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven&#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) -->
|
<!-- Pricing Options (Rush / Discount) -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -357,7 +400,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,
|
||||||
@@ -374,7 +418,7 @@
|
|||||||
"discountType": @Json.Serialize(Model.DiscountType),
|
"discountType": @Json.Serialize(Model.DiscountType),
|
||||||
"discountValue": @Model.DiscountValue,
|
"discountValue": @Model.DiscountValue,
|
||||||
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
"isRushJob": @Json.Serialize(Model.IsRushJob),
|
||||||
"ovenCostId": null,
|
"ovenCostId": @Json.Serialize(Model.OvenCostId),
|
||||||
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
"areaUnit": @Json.Serialize((string?)ViewBag.AreaUnit),
|
||||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||||
|
|||||||
@@ -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