Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId);
|
int invoiceId);
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId);
|
int quoteId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
TransferEfficiency = coatDto.TransferEfficiency,
|
TransferEfficiency = coatDto.TransferEfficiency,
|
||||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||||
PowderToOrder = coatDto.PowderToOrder,
|
PowderToOrder = coatDto.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||||
Notes = coatDto.Notes,
|
Notes = coatDto.Notes,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId)
|
int invoiceId)
|
||||||
{
|
{
|
||||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Invoice {invoiceNumber}",
|
Description = $"Invoice {invoiceNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId)
|
int quoteId)
|
||||||
{
|
{
|
||||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Deposit for quote {quoteNumber}",
|
Description = $"Deposit for quote {quoteNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
|
|||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
|
// Pre-load coats so we can derive color names for invoice line items
|
||||||
|
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
|
||||||
|
var allCoats = activeItemIds.Any()
|
||||||
|
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
|
||||||
|
: new List<JobItemCoat>();
|
||||||
|
var coatsByItem = allCoats
|
||||||
|
.GroupBy(c => c.JobItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||||||
|
|
||||||
// Validate no existing active invoice for this job (voided ones are kept as history)
|
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||||
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||||
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
|
|||||||
revenueAccountId = ci.RevenueAccountId;
|
revenueAccountId = ci.RevenueAccountId;
|
||||||
revenueAccountId ??= defaultRevenueAccount?.Id;
|
revenueAccountId ??= defaultRevenueAccount?.Id;
|
||||||
|
|
||||||
|
// Derive color from coats when the item itself has no explicit color set
|
||||||
|
var derivedColor = item.ColorName;
|
||||||
|
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
|
||||||
|
{
|
||||||
|
var coatColors = itemCoats
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.ColorName))
|
||||||
|
.Select(c => c.ColorName!);
|
||||||
|
derivedColor = string.Join(" / ", coatColors);
|
||||||
|
}
|
||||||
|
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
SourceJobItemId = item.Id,
|
SourceJobItemId = item.Id,
|
||||||
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
|
|||||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||||
UnitPrice = item.UnitPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
TotalPrice = item.TotalPrice,
|
TotalPrice = item.TotalPrice,
|
||||||
ColorName = item.ColorName,
|
ColorName = derivedColor,
|
||||||
Notes = item.Notes,
|
Notes = item.Notes,
|
||||||
DisplayOrder = order++,
|
DisplayOrder = order++,
|
||||||
RevenueAccountId = revenueAccountId
|
RevenueAccountId = revenueAccountId
|
||||||
@@ -3016,6 +3035,50 @@ public class InvoicesController : Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single invoice line item.
|
||||||
|
/// Blocked on paid/voided invoices (same gate as the full Edit action).
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
|
||||||
|
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
|
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
|
||||||
|
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
|
||||||
|
var newSubTotal = allItems.Sum(i => i.TotalPrice);
|
||||||
|
invoice.SubTotal = newSubTotal;
|
||||||
|
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
|
||||||
|
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = invoice.SubTotal,
|
||||||
|
taxAmount = invoice.TaxAmount,
|
||||||
|
total = invoice.Total,
|
||||||
|
balanceDue = invoice.BalanceDue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns logo bytes and content type for PDF generation.
|
/// Returns logo bytes and content type for PDF generation.
|
||||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||||
@@ -3031,3 +3094,11 @@ public class InvoicesController : Controller
|
|||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchInvoiceItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
|
||||||
|
// so completed/cancelled jobs don't clutter the first screen.
|
||||||
|
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
|
||||||
|
return RedirectToAction("Index", new { statusGroup = "active" });
|
||||||
|
|
||||||
// Create and validate grid request
|
// Create and validate grid request
|
||||||
var gridRequest = new GridRequest
|
var gridRequest = new GridRequest
|
||||||
{
|
{
|
||||||
@@ -141,6 +146,13 @@ public class JobsController : Controller
|
|||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||||
}
|
}
|
||||||
|
else if (statusGroup == "completed")
|
||||||
|
{
|
||||||
|
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||||
|
}
|
||||||
|
// "all" or unknown group: no filter applied (show every status)
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
@@ -195,6 +207,27 @@ public class JobsController : Controller
|
|||||||
gridRequest, jobDtos,
|
gridRequest, jobDtos,
|
||||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||||
|
|
||||||
|
// Pill badge counts — always global (not scoped to current filter/page)
|
||||||
|
var today = DateTime.Today;
|
||||||
|
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||||
|
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.DueDate < today
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||||
|
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||||
|
|
||||||
// Set ViewBag for sorting
|
// Set ViewBag for sorting
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.StatusGroup = statusGroup;
|
ViewBag.StatusGroup = statusGroup;
|
||||||
@@ -477,6 +510,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
|
||||||
}),
|
}),
|
||||||
prepServices = ji.PrepServices.Select(ps => new {
|
prepServices = ji.PrepServices.Select(ps => new {
|
||||||
@@ -1078,6 +1112,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 +1185,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 +1253,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 +1300,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 +1431,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 +1660,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 +1861,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);
|
||||||
@@ -2936,6 +2980,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
|
||||||
@@ -3116,7 +3161,8 @@ public class JobsController : Controller
|
|||||||
InventoryItemId = c.InventoryItemId,
|
InventoryItemId = c.InventoryItemId,
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -4170,9 +4216,69 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
|
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||||
|
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.JobItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var delta = item.TotalPrice - oldTotal;
|
||||||
|
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
|
||||||
|
|
||||||
|
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent
|
||||||
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
{
|
||||||
|
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
|
if (pb != null)
|
||||||
|
{
|
||||||
|
pb.ItemsSubtotal += delta;
|
||||||
|
pb.SubtotalBeforeDiscount += delta;
|
||||||
|
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
|
||||||
|
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
|
||||||
|
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
|
||||||
|
job.FinalPrice = pb.Total;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
finalPrice = job.FinalPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
|
public class PatchJobItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
public class LogMaterialRequest
|
public class LogMaterialRequest
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
|
|||||||
@@ -127,8 +127,6 @@ public class PaymentController : Controller
|
|||||||
return BadRequest(new { error = "Invalid payment amount." });
|
return BadRequest(new { error = "Invalid payment amount." });
|
||||||
|
|
||||||
var surcharge = CalculateSurcharge(request.Amount, company!);
|
var surcharge = CalculateSurcharge(request.Amount, company!);
|
||||||
var customer = await _context.Customers.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreatePaymentIntentAsync(
|
await _stripeConnect.CreatePaymentIntentAsync(
|
||||||
@@ -136,7 +134,6 @@ public class PaymentController : Controller
|
|||||||
invoiceTotal: request.Amount,
|
invoiceTotal: request.Amount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customer?.Email ?? string.Empty,
|
|
||||||
invoiceNumber: invoice.InvoiceNumber,
|
invoiceNumber: invoice.InvoiceNumber,
|
||||||
invoiceId: invoice.Id);
|
invoiceId: invoice.Id);
|
||||||
|
|
||||||
@@ -296,7 +293,6 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
||||||
var surcharge = CalculateSurcharge(depositAmount, company!);
|
var surcharge = CalculateSurcharge(depositAmount, company!);
|
||||||
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
||||||
@@ -304,7 +300,6 @@ public class PaymentController : Controller
|
|||||||
depositAmount: depositAmount,
|
depositAmount: depositAmount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customerEmail,
|
|
||||||
quoteNumber: quote.QuoteNumber,
|
quoteNumber: quote.QuoteNumber,
|
||||||
quoteId: quote.Id);
|
quoteId: quote.Id);
|
||||||
|
|
||||||
|
|||||||
@@ -3824,6 +3824,49 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single quote line item.
|
||||||
|
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
|
||||||
|
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.QuoteItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
// Cascade delta through stored totals without re-running the pricing engine
|
||||||
|
var delta = item.TotalPrice - oldTotal;
|
||||||
|
quote.ItemsSubtotal += delta;
|
||||||
|
quote.SubTotal += delta;
|
||||||
|
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
|
||||||
|
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
|
||||||
|
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = quote.SubTotal,
|
||||||
|
taxAmount = quote.TaxAmount,
|
||||||
|
total = quote.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request model for AJAX pricing calculation
|
// Request model for AJAX pricing calculation
|
||||||
@@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public int StatusId { get; set; }
|
public int StatusId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchQuoteItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -265,12 +265,15 @@ public static class HelpKnowledgeBase
|
|||||||
**Job Priorities (color-coded):**
|
**Job Priorities (color-coded):**
|
||||||
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
||||||
|
|
||||||
|
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
|
||||||
|
|
||||||
**How to create a job:**
|
**How to create a job:**
|
||||||
1. Go to [Jobs](/Jobs) → "New Job"
|
1. Go to [Jobs](/Jobs) → "New Job"
|
||||||
2. Select customer
|
2. Select customer
|
||||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||||
4. Set priority, due date, assigned worker, special instructions
|
4. Set priority, due date, assigned worker, special instructions
|
||||||
5. Save
|
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||||
|
6. Save
|
||||||
|
|
||||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||||
|
|
||||||
@@ -302,7 +305,9 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||||
|
|
||||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
|
||||||
|
|
||||||
|
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||||
|
|
||||||
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
<span class=”text-muted”>Not set — invoices go to contact email</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,13 @@
|
|||||||
a priority level, a due date, and one or more line items describing the work to be performed.
|
a priority level, a due date, and one or more line items describing the work to be performed.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list is
|
You can find Jobs under <strong>Operations › Jobs</strong> in the left sidebar. The list
|
||||||
searchable and sortable by job number, status, priority, scheduled date, due date, and price. Jobs
|
opens on the <strong>On Floor</strong> filter by default, showing only active in-progress work
|
||||||
can be created manually or converted automatically from an approved quote — no need to re-enter
|
so completed jobs don’t clutter the screen. Use the filter pills at the top to switch
|
||||||
|
views: <strong>All</strong>, <strong>On Floor</strong>, <strong>Overdue</strong>,
|
||||||
|
<strong>Ready</strong> (awaiting pickup), and <strong>Completed</strong>. The list is sortable
|
||||||
|
and searchable by job number, status, priority, scheduled date, due date, and price. Jobs can be
|
||||||
|
created manually or converted automatically from an approved quote — no need to re-enter
|
||||||
information that is already in the system.
|
information that is already in the system.
|
||||||
</p>
|
</p>
|
||||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
@@ -54,6 +58,11 @@
|
|||||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
Optionally expand <strong>Oven & Batch Settings</strong> to assign a named oven, set the
|
||||||
|
number of batches, and enter the cure cycle time. These values feed directly into the oven cost
|
||||||
|
calculation so the job’s pricing reflects the actual oven run.
|
||||||
|
</li>
|
||||||
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
<li class="mb-2">Click <strong>Save Job</strong>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
@@ -353,7 +362,7 @@
|
|||||||
<li>If an invoice already exists, you will see a link to open it.</li>
|
<li>If an invoice already exists, you will see a link to open it.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job's line items and the final pricing.</li>
|
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates a new invoice pre-filled with all the job’s line items and the final pricing. Line item descriptions include the coat color(s) for each item (e.g., <em>Gloss Black / Satin Clear</em>), which helps customers distinguish repeated items such as multiple sets of calipers.</li>
|
||||||
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
<li class="mb-2">Review the invoice, confirm the due date, and save it.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
@@ -370,6 +379,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="completing-a-job" class="mb-5">
|
||||||
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
|
<i class="bi bi-check-circle text-primary me-2"></i>Completing a Job
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
When work is done and the parts have passed quality check, click the <strong>Complete Job</strong>
|
||||||
|
button on the Job Details page. A modal opens where you confirm:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
<li><strong>Completion date</strong> — defaults to today.</li>
|
||||||
|
<li><strong>Actual hours spent</strong> on the job.</li>
|
||||||
|
<li><strong>Final price</strong> — pre-filled from the job; adjust if needed.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Powder usage</strong> — if the job used powder from inventory, you are asked
|
||||||
|
to enter actual lbs used. The modal groups all coats by <em>unique powder color</em> and
|
||||||
|
shows one input row per powder, regardless of how many items or coats used that color.
|
||||||
|
This avoids entering the same number repeatedly. Any lbs already scanned via QR code on
|
||||||
|
the shop floor are credited automatically — you only enter the remaining amount.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Once confirmed, the job advances to <span class="badge bg-success">Completed</span> status,
|
||||||
|
inventory is updated, and you are prompted to create an invoice if one does not already exist.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Shop floor workers who complete a job via QR scan bypass the modal — the SMS
|
||||||
|
notification is sent immediately using the configured template. Managers and admins
|
||||||
|
get the full modal with a compose step before the SMS goes out.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="photos-notes" class="mb-5">
|
<section id="photos-notes" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||||
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
<i class="bi bi-camera text-primary me-2"></i>Photos and Notes
|
||||||
@@ -699,6 +742,7 @@
|
|||||||
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#job-items">Job Items</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#converting-from-quote">Converting from a Quote</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#creating-an-invoice">Creating an Invoice</a>
|
||||||
|
<a class="nav-link py-1 px-3 small text-body" href="#completing-a-job">Completing a Job</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||||
|
|||||||
@@ -230,21 +230,21 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model.InvoiceItems)
|
@foreach (var item in Model.InvoiceItems)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">@item.Description</div>
|
<span class="fw-semibold" data-inline-field="description" data-raw-value="@item.Description">@item.Description</span>
|
||||||
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
@if (!string.IsNullOrWhiteSpace(item.ColorName))
|
||||||
{
|
{
|
||||||
<small class="text-muted">@item.ColorName</small>
|
<small class="text-muted d-block">@item.ColorName</small>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
@if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||||
{
|
{
|
||||||
<small class="text-muted d-block">@item.Notes</small>
|
<small class="text-muted d-block">@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity.ToString("G")</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity.ToString("G")</span></td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<span class="text-muted">Subtotal</span>
|
<span class="text-muted">Subtotal</span>
|
||||||
<span>@Model.SubTotal.ToString("C")</span>
|
<span id="inv-subtotal">@Model.SubTotal.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.DiscountAmount > 0)
|
@if (Model.DiscountAmount > 0)
|
||||||
{
|
{
|
||||||
@@ -276,12 +276,12 @@
|
|||||||
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
<span class="badge bg-warning-subtle text-warning-emphasis ms-1 small fw-normal">@Model.SalesTaxAccountName</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span>@Model.TaxAmount.ToString("C")</span>
|
<span id="inv-tax">@Model.TaxAmount.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1 fs-5">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>@Model.Total.ToString("C")</span>
|
<span id="inv-total">@Model.Total.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.AmountPaid > 0)
|
@if (Model.AmountPaid > 0)
|
||||||
{
|
{
|
||||||
@@ -291,7 +291,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||||
<span>Balance Due</span>
|
<span>Balance Due</span>
|
||||||
<span class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
<span id="inv-balance" class="@(Model.BalanceDue > 0 ? "text-danger" : "text-success")">@Model.BalanceDue.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -869,9 +869,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showSendModal)
|
}
|
||||||
{
|
|
||||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
@if (showSendModal)
|
||||||
|
{
|
||||||
|
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
|
||||||
|
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
|
||||||
|
where the customer's email was added after an SMS-only initial send. -->
|
||||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
<div class="modal 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
|
||||||
@@ -904,7 +908,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canPay)
|
@if (canPay)
|
||||||
@@ -1439,6 +1442,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/inline-item-edit.js"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Invoices")',
|
||||||
|
canEdit: @Json.Serialize(canEdit),
|
||||||
|
totals: {
|
||||||
|
subtotal: '#inv-subtotal',
|
||||||
|
tax: '#inv-tax',
|
||||||
|
total: '#inv-total',
|
||||||
|
balance: '#inv-balance'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
function submitSendInvoice(sendEmail, sendSms) {
|
function submitSendInvoice(sendEmail, sendSms) {
|
||||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||||
|
|||||||
@@ -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")",
|
||||||
|
|||||||
@@ -324,6 +324,7 @@
|
|||||||
@* -- Catalog Products -- *@
|
@* -- Catalog Products -- *@
|
||||||
@if (catalogItems.Any())
|
@if (catalogItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -340,9 +341,9 @@
|
|||||||
@foreach (var item in catalogItems)
|
@foreach (var item in catalogItems)
|
||||||
{
|
{
|
||||||
var catIdx = allItems.IndexOf(item);
|
var catIdx = allItems.IndexOf(item);
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (item.Coats != null && item.Coats.Any())
|
@if (item.Coats != null && item.Coats.Any())
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
@@ -398,9 +399,9 @@
|
|||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@catIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -412,11 +413,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Custom Work -- *@
|
@* -- Custom Work -- *@
|
||||||
@if (customItems.Any())
|
@if (customItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -437,6 +440,7 @@
|
|||||||
{
|
{
|
||||||
var custIdx = allItems.IndexOf(item);
|
var custIdx = allItems.IndexOf(item);
|
||||||
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
|
// Use stored PowderToOrder per coat; fall back to calculating from efficiency data
|
||||||
|
// Note: row has data-item-id for inline editing
|
||||||
decimal totalPowderNeeded = 0;
|
decimal totalPowderNeeded = 0;
|
||||||
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
|
if (item.Coats != null && item.Coats.Any(c => c.PowderToOrder > 0))
|
||||||
{
|
{
|
||||||
@@ -455,9 +459,9 @@
|
|||||||
{
|
{
|
||||||
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
|
totalPowderNeeded = (item.SurfaceAreaSqFt * item.Quantity) / (30m * 0.65m);
|
||||||
}
|
}
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (item.RequiresSandblasting || item.RequiresMasking)
|
@if (item.RequiresSandblasting || item.RequiresMasking)
|
||||||
{
|
{
|
||||||
<br />
|
<br />
|
||||||
@@ -525,7 +529,7 @@
|
|||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (item.SurfaceAreaSqFt > 0)
|
@if (item.SurfaceAreaSqFt > 0)
|
||||||
{
|
{
|
||||||
@@ -550,8 +554,8 @@
|
|||||||
}
|
}
|
||||||
else { <span class="text-muted">—</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@custIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -563,11 +567,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Labor -- *@
|
@* -- Labor -- *@
|
||||||
@if (laborItems.Any())
|
@if (laborItems.Any())
|
||||||
{
|
{
|
||||||
|
<div class="d-none d-lg-block">
|
||||||
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-hover table-sm">
|
<table class="table table-hover table-sm">
|
||||||
@@ -585,15 +591,15 @@
|
|||||||
@foreach (var item in laborItems)
|
@foreach (var item in laborItems)
|
||||||
{
|
{
|
||||||
var labIdx = allItems.IndexOf(item);
|
var labIdx = allItems.IndexOf(item);
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
@if (!string.IsNullOrEmpty(item.Notes))
|
@if (!string.IsNullOrEmpty(item.Notes))
|
||||||
{
|
{
|
||||||
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
<br /><small class="text-muted"><i class="bi bi-sticky me-1"></i>@item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">@item.Quantity</td>
|
<td class="text-center"><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
@if (item.EstimatedMinutes > 0)
|
@if (item.EstimatedMinutes > 0)
|
||||||
{
|
{
|
||||||
@@ -601,8 +607,8 @@
|
|||||||
}
|
}
|
||||||
else { <span class="text-muted">—</span> }
|
else { <span class="text-muted">—</span> }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">@item.UnitPrice.ToString("C")</td>
|
<td class="text-end"><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
|
<td class="text-end fw-semibold" data-line-total>@item.TotalPrice.ToString("C")</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button type="button" class="btn btn-outline-secondary" onclick="openWizard(@labIdx)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
@@ -614,6 +620,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* -- Mobile cards -- *@
|
@* -- Mobile cards -- *@
|
||||||
@@ -653,7 +660,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>
|
||||||
@@ -1679,7 +1689,7 @@
|
|||||||
}
|
}
|
||||||
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
<div class="d-flex justify-content-between fw-bold border-top pt-2 mt-1">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>@jobPb.Total.ToString("C")</span>
|
<span class="job-final-price-display">@jobPb.Total.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
var jobTotalDirectCost = jobPb.MaterialCosts + jobPb.LaborCosts + jobPb.EquipmentCosts + jobPb.OvenBatchCost + jobPb.FacilityOverheadCost + jobPb.ShopSuppliesAmount;
|
||||||
@@ -1708,7 +1718,7 @@
|
|||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-muted small mb-1">Final Price</label>
|
<label class="text-muted small mb-1">Final Price</label>
|
||||||
<h3 class="mb-0 text-primary">@Model.FinalPrice.ToString("C")</h3>
|
<h3 class="mb-0 text-primary job-final-price-display">@Model.FinalPrice.ToString("C")</h3>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -2406,6 +2416,16 @@
|
|||||||
<link rel="stylesheet" href="~/css/job-photos.css" />
|
<link rel="stylesheet" href="~/css/job-photos.css" />
|
||||||
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
<script src="~/js/job-photos.js" asp-append-version="true"></script>
|
||||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Jobs")',
|
||||||
|
canEdit: true,
|
||||||
|
totals: {
|
||||||
|
finalPrice: '.job-final-price-display'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// -- Inline date editing ----------------------------------------------
|
// -- Inline date editing ----------------------------------------------
|
||||||
const jobId = @Model.Id;
|
const jobId = @Model.Id;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@{
|
@{
|
||||||
var _wip = Model.Items.Count(j => j.StatusIsWIP);
|
var _allCount = (int)(ViewBag.AllJobCount ?? 0);
|
||||||
var _done = Model.Items.Count(j => j.StatusCode == "COMPLETED" || j.StatusCode == "READYFORPICKUP" || j.StatusCode == "DELIVERED");
|
var _wip = (int)(ViewBag.ActiveCount ?? 0);
|
||||||
var _overdue = Model.Items.Count(j => j.DueDate.HasValue && j.DueDate.Value < DateTime.Now && j.StatusCode != "COMPLETED" && j.StatusCode != "READYFORPICKUP" && j.StatusCode != "DELIVERED" && j.StatusCode != "CANCELLED");
|
var _done = (int)(ViewBag.CompletedCount ?? 0);
|
||||||
|
var _ready = (int)(ViewBag.ReadyCount ?? 0);
|
||||||
|
var _overdue = (int)(ViewBag.OverdueCount ?? 0);
|
||||||
var _value = Model.Items.Sum(j => j.FinalPrice);
|
var _value = Model.Items.Sum(j => j.FinalPrice);
|
||||||
}
|
}
|
||||||
<div class="pcl-metric-strip">
|
<div class="pcl-metric-strip">
|
||||||
@@ -39,23 +41,23 @@
|
|||||||
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
|
||||||
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
|
||||||
</div>
|
</div>
|
||||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-x me-1"></i>Clear Filter
|
<i class="bi bi-x me-1"></i>Clear Filter
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string))
|
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string) && ViewBag.StatusGroup != "active" && ViewBag.StatusGroup != "all")
|
||||||
{
|
{
|
||||||
var groupLabel = ViewBag.StatusGroup == "active" ? "Active Jobs (excluding completed & cancelled)"
|
var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
||||||
: ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
|
: ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)"
|
||||||
: ViewBag.StatusGroup;
|
: (string)ViewBag.StatusGroup;
|
||||||
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-funnel-fill me-2"></i>
|
<i class="bi bi-funnel-fill me-2"></i>
|
||||||
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
Showing: <strong>@groupLabel</strong> — @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
|
||||||
</div>
|
</div>
|
||||||
<a href="@Url.Action("Index")" class="btn btn-sm btn-outline-secondary">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-x me-1"></i>Show All
|
<i class="bi bi-x me-1"></i>Back to On Floor
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,8 @@
|
|||||||
@{
|
@{
|
||||||
var _activeGroup = ViewBag.StatusGroup as string;
|
var _activeGroup = ViewBag.StatusGroup as string;
|
||||||
var _activeSearch = ViewBag.SearchTerm as string;
|
var _activeSearch = ViewBag.SearchTerm as string;
|
||||||
var _noFilter = string.IsNullOrEmpty(_activeGroup) && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
// "all" is the explicit show-everything group (bare URL now redirects to "active")
|
||||||
|
var _noFilter = _activeGroup == "all" && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
|
||||||
}
|
}
|
||||||
<!-- Jobs Table Card -->
|
<!-- Jobs Table Card -->
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
@@ -135,8 +138,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Row 2: quick-view pills -->
|
<!-- Row 2: quick-view pills -->
|
||||||
<div class="pcl-pill-group">
|
<div class="pcl-pill-group">
|
||||||
<a href="@Url.Action("Index")" class="pcl-pill @(_noFilter ? "active" : "")">
|
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(_noFilter ? "active" : "")">
|
||||||
All <span class="pcl-pill-count">@Model.TotalCount</span>
|
All <span class="pcl-pill-count">@_allCount</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
|
||||||
On floor <span class="pcl-pill-count">@_wip</span>
|
On floor <span class="pcl-pill-count">@_wip</span>
|
||||||
@@ -145,7 +148,10 @@
|
|||||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||||
Ready <span class="pcl-pill-count">@_done</span>
|
Ready <span class="pcl-pill-count">@_ready</span>
|
||||||
|
</a>
|
||||||
|
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
|
||||||
|
Completed <span class="pcl-pill-count">@_done</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -276,14 +276,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in catalogItems)
|
@foreach (var item in catalogItems)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
@{
|
@{
|
||||||
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
var displayDescription = item.Description == "Product Item" || string.IsNullOrWhiteSpace(item.Description)
|
||||||
? (item.CatalogItemName ?? "Catalog Item")
|
? (item.CatalogItemName ?? "Catalog Item")
|
||||||
: item.Description;
|
: item.Description;
|
||||||
}
|
}
|
||||||
<strong>@displayDescription</strong>
|
<span data-inline-field="description" data-raw-value="@displayDescription"><strong>@displayDescription</strong></span>
|
||||||
@if (item.CatalogItemId.HasValue &&
|
@if (item.CatalogItemId.HasValue &&
|
||||||
item.Description != "Product Item" &&
|
item.Description != "Product Item" &&
|
||||||
!string.IsNullOrWhiteSpace(item.Description))
|
!string.IsNullOrWhiteSpace(item.Description))
|
||||||
@@ -354,9 +354,9 @@
|
|||||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@item.Quantity</td>
|
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td>@item.UnitPrice.ToString("C")</td>
|
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -394,9 +394,9 @@
|
|||||||
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
var totalSqFt = item.SurfaceAreaSqFt * item.Quantity;
|
||||||
var powderOnPart = totalSqFt / coverageRate;
|
var powderOnPart = totalSqFt / coverageRate;
|
||||||
var totalPowderNeeded = powderOnPart / transferEff;
|
var totalPowderNeeded = powderOnPart / transferEff;
|
||||||
<tr>
|
<tr data-item-id="@item.Id">
|
||||||
<td>
|
<td>
|
||||||
<strong>@item.Description</strong>
|
<span data-inline-field="description" data-raw-value="@item.Description"><strong>@item.Description</strong></span>
|
||||||
|
|
||||||
@* Display coating layers *@
|
@* Display coating layers *@
|
||||||
@if (item.Coats != null && item.Coats.Any())
|
@if (item.Coats != null && item.Coats.Any())
|
||||||
@@ -474,7 +474,7 @@
|
|||||||
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
<small class="text-muted"><i class="bi bi-sticky"></i> <strong>Notes:</strong> @item.Notes</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@item.Quantity</td>
|
<td><span data-inline-field="quantity" data-raw-value="@item.Quantity">@item.Quantity</span></td>
|
||||||
<td>
|
<td>
|
||||||
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit
|
||||||
<br /><small class="text-muted">per item</small>
|
<br /><small class="text-muted">per item</small>
|
||||||
@@ -487,8 +487,8 @@
|
|||||||
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
|
||||||
<br /><small class="text-muted">total batch</small>
|
<br /><small class="text-muted">total batch</small>
|
||||||
</td>
|
</td>
|
||||||
<td>@item.UnitPrice.ToString("C")</td>
|
<td><span data-inline-field="unitPrice" data-raw-value="@item.UnitPrice">@item.UnitPrice.ToString("C")</span></td>
|
||||||
<td><strong>@item.TotalPrice.ToString("C")</strong></td>
|
<td data-line-total><strong>@item.TotalPrice.ToString("C")</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<strong>@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
<strong id="quote-subtotal">@Model.PricingBreakdown.SubtotalBeforeDiscount.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||||
@@ -1075,7 +1075,7 @@
|
|||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
<span>Tax (@Model.PricingBreakdown.TaxPercent.ToString("G29")%):</span>
|
||||||
<strong>@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
<strong id="quote-tax">@Model.PricingBreakdown.TaxAmount.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between mb-0">
|
<div class="d-flex justify-content-between mb-0">
|
||||||
<h5>Total:</h5>
|
<h5>Total:</h5>
|
||||||
<h5 class="text-primary"><strong>@Model.Total.ToString("C")</strong></h5>
|
<h5 class="text-primary"><strong id="quote-total">@Model.Total.ToString("C")</strong></h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
@if (Model.PricingBreakdown.DiscountAmount > 0)
|
||||||
@@ -2262,6 +2262,18 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
<script src="~/js/customer-change.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/inline-item-edit.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
window.inlineItemEdit = {
|
||||||
|
patchUrl: '@Url.Action("PatchItem", "Quotes")',
|
||||||
|
canEdit: true,
|
||||||
|
totals: {
|
||||||
|
subtotal: '#quote-subtotal',
|
||||||
|
tax: '#quote-tax',
|
||||||
|
total: '#quote-total'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
function sendQuoteToAdHocEmail(quoteId) {
|
function sendQuoteToAdHocEmail(quoteId) {
|
||||||
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
const email = (document.getElementById('quoteAdHocEmailInput').value ?? '').trim();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1152,3 +1152,14 @@ a.tag-index-badge:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mw-lg { max-width: 640px; }
|
.mw-lg { max-width: 640px; }
|
||||||
|
|
||||||
|
/* ── Inline item edit ───────────────────────────────────────── */
|
||||||
|
.inline-editable:hover {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.inline-edit-input {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Shared inline-edit behaviour for quote, job, and invoice item rows.
|
||||||
|
/// Activated when the page sets window.inlineItemEdit = { patchUrl, canEdit, totals }.
|
||||||
|
/// totals: { subtotal, tax, total, finalPrice, balance } — CSS selectors, any subset.
|
||||||
|
/// </summary>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const cfg = window.inlineItemEdit;
|
||||||
|
if (!cfg || !cfg.canEdit) return;
|
||||||
|
|
||||||
|
function fmt(val) {
|
||||||
|
return val.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'alert alert-danger alert-permanent position-fixed bottom-0 end-0 m-3 shadow';
|
||||||
|
el.style.zIndex = '9999';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotals(data) {
|
||||||
|
const t = cfg.totals || {};
|
||||||
|
[
|
||||||
|
[t.subtotal, data.subtotal],
|
||||||
|
[t.tax, data.taxAmount],
|
||||||
|
[t.total, data.total],
|
||||||
|
[t.finalPrice, data.finalPrice],
|
||||||
|
[t.balance, data.balanceDue],
|
||||||
|
].forEach(([sel, val]) => {
|
||||||
|
if (sel && val !== undefined && val !== null) {
|
||||||
|
document.querySelectorAll(sel).forEach(el => { el.textContent = fmt(val); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEditable(span) {
|
||||||
|
const field = span.dataset.inlineField;
|
||||||
|
const row = span.closest('tr[data-item-id]');
|
||||||
|
if (!row) return;
|
||||||
|
const itemId = row.dataset.itemId;
|
||||||
|
|
||||||
|
const rawVal = span.dataset.rawValue ?? span.textContent.trim();
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control form-control-sm inline-edit-input';
|
||||||
|
if (field === 'description') {
|
||||||
|
input.type = 'text';
|
||||||
|
input.style.minWidth = '140px';
|
||||||
|
} else {
|
||||||
|
input.type = 'number';
|
||||||
|
input.step = '0.01';
|
||||||
|
input.min = '0';
|
||||||
|
input.style.width = '80px';
|
||||||
|
}
|
||||||
|
input.value = rawVal;
|
||||||
|
|
||||||
|
// Stash current rendered markup so we can revert
|
||||||
|
const savedHTML = span.innerHTML;
|
||||||
|
span.innerHTML = '';
|
||||||
|
span.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
let committed = false;
|
||||||
|
|
||||||
|
function revert() {
|
||||||
|
span.innerHTML = savedHTML;
|
||||||
|
attachListeners(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
|
||||||
|
const newVal = input.value.trim();
|
||||||
|
if (newVal === '' || (field !== 'description' && isNaN(parseFloat(newVal)))) {
|
||||||
|
revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read sibling raw values from other editable cells in the same row
|
||||||
|
function siblingRaw(f) {
|
||||||
|
const s = row.querySelector(`[data-inline-field="${f}"]`);
|
||||||
|
if (!s) return null;
|
||||||
|
// If that sibling is currently showing an input (concurrent edit, unlikely), fall back
|
||||||
|
const inp = s.querySelector('input.inline-edit-input');
|
||||||
|
if (inp) return inp.value;
|
||||||
|
return s.dataset.rawValue ?? s.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = field === 'description' ? newVal : (siblingRaw('description') ?? '');
|
||||||
|
const quantity = parseFloat(field === 'quantity' ? newVal : (siblingRaw('quantity') ?? '1'));
|
||||||
|
const unitPrice = parseFloat(field === 'unitPrice' ? newVal : (siblingRaw('unitPrice') ?? '0'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(cfg.patchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'RequestVerificationToken': csrfToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ itemId: parseInt(itemId, 10), description, quantity, unitPrice })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showError(err.error ?? 'Could not save — try again.');
|
||||||
|
revert();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Update this span's display and stored raw value
|
||||||
|
if (field === 'description') {
|
||||||
|
const strong = span.querySelector('strong');
|
||||||
|
if (strong) { strong.textContent = newVal; }
|
||||||
|
else { span.innerHTML = `<strong>${newVal}</strong>`; }
|
||||||
|
span.dataset.rawValue = newVal;
|
||||||
|
} else if (field === 'quantity') {
|
||||||
|
span.dataset.rawValue = quantity;
|
||||||
|
span.textContent = quantity % 1 === 0 ? quantity.toFixed(0) : quantity.toString();
|
||||||
|
} else if (field === 'unitPrice') {
|
||||||
|
span.dataset.rawValue = unitPrice;
|
||||||
|
span.textContent = fmt(unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update line total cell
|
||||||
|
const totalCell = row.querySelector('[data-line-total]');
|
||||||
|
if (totalCell) totalCell.textContent = fmt(data.lineTotal);
|
||||||
|
|
||||||
|
// Update document-level totals
|
||||||
|
updateTotals(data);
|
||||||
|
|
||||||
|
// Re-attach click listener for next edit
|
||||||
|
attachListeners(span);
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
showError('Could not save — check your connection and try again.');
|
||||||
|
revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { committed = true; revert(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners(span) {
|
||||||
|
span.style.cursor = 'text';
|
||||||
|
span.title = 'Click to edit';
|
||||||
|
span.classList.add('inline-editable');
|
||||||
|
span.addEventListener('click', () => makeEditable(span), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('[data-inline-field]').forEach(attachListeners);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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