Compare commits

...

7 Commits

Author SHA1 Message Date
spouliot 711cd01cd3 Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:59:32 -04:00
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
39 changed files with 35366 additions and 58 deletions
@@ -0,0 +1,35 @@
namespace PowderCoating.Application.DTOs.Customer;
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
public class CustomerTimelineEventDto
{
public DateTime Date { get; set; }
public string Icon { get; set; } = string.Empty;
public string BadgeColor { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public decimal? Amount { get; set; }
public int? EntityId { get; set; }
public string? LinkController { get; set; }
public string? LinkAction { get; set; }
}
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
public class CustomerLifetimeStatsDto
{
public int TotalJobs { get; set; }
public int ActiveJobs { get; set; }
/// <summary>Sum of Total on non-voided invoices.</summary>
public decimal TotalRevenue { get; set; }
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
public decimal TotalCollected { get; set; }
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
public decimal AverageJobValue { get; set; }
public DateTime? LastJobDate { get; set; }
public int? DaysSinceLastJob { get; set; }
public int TotalQuotes { get; set; }
public int TotalInvoices { get; set; }
public decimal OpenBalance { get; set; }
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
public int? LastJobId { get; set; }
}
@@ -57,6 +57,7 @@ public class InvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? ExternalReference { get; set; }
public int? SalesTaxAccountId { get; set; }
public string? SalesTaxAccountName { get; set; }
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
}
@@ -52,6 +52,7 @@ public class JobDto
public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; }
public string? Tags { get; set; }
@@ -114,6 +115,7 @@ public class JobListDto
public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; }
@@ -167,6 +169,7 @@ public class CreateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")]
@@ -252,6 +255,7 @@ public class UpdateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")]
@@ -107,6 +107,7 @@ public class QuoteDto
public string? Terms { get; set; }
public string? Notes { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; }
// Items
@@ -234,6 +235,7 @@ public class CreateQuoteDto
[Display(Name = "Customer PO Number")]
[StringLength(50)]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")]
[StringLength(500)]
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
[Display(Name = "Customer PO Number")]
[StringLength(50)]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")]
[StringLength(500)]
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
CreateMap<Invoice, InvoiceDto>()
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
? (s.Customer.IsCommercial
? s.Customer.CompanyName
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
c.Item().Text($"Job #: {invoice.JobNumber}");
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
c.Item().Text($"PO #: {invoice.CustomerPO}");
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
c.Item().Text($"Project: {invoice.ProjectName}");
});
});
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
});
}
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Project:").FontSize(9);
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
});
}
});
}
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>
/// Early payment discount percentage (e.g., 2 means 2% discount).
+1
View File
@@ -47,6 +47,7 @@ public class Job : BaseEntity
// Additional Information
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } // Internal notes from quote
public string? Tags { get; set; }
+1
View File
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
public string? Terms { get; set; }
public string? Notes { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; }
// Conversion tracking
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
public virtual Customer Customer { get; set; } = null!;
}
/// <summary>
/// Records an inventory item as a preferred powder for a specific customer.
/// Shown on Customer Details for faster quoting of repeat orders.
/// </summary>
public class CustomerPreferredPowder : BaseEntity
{
public int CustomerId { get; set; }
public int InventoryItemId { get; set; }
public string? Notes { get; set; }
public virtual Customer Customer { get; set; } = null!;
public virtual InventoryItem InventoryItem { get; set; } = null!;
}
public class JobStatusHistory : BaseEntity
{
public int JobId { get; set; }
@@ -43,6 +43,7 @@ public interface IUnitOfWork : IDisposable
IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; }
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; }
IRepository<PricingTier> PricingTiers { get; }
@@ -230,6 +230,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<JobNote> JobNotes { get; set; }
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
public DbSet<CustomerNote> CustomerNotes { get; set; }
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
@@ -551,6 +553,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
@@ -1719,6 +1723,23 @@ modelBuilder.Entity<Job>()
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.Customer)
.WithMany()
.HasForeignKey(p => p.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.InventoryItem)
.WithMany()
.HasForeignKey(p => p.InventoryItemId)
.OnDelete(DeleteBehavior.Restrict);
// ===================================================================
// END PERFORMANCE OPTIMIZATION INDEXES
// ===================================================================
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProjectNameToQuotesAndJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Quotes",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Quotes");
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceProjectName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
}
}
@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomerPreferredPowders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomerPreferredPowders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<int>(type: "int", nullable: false),
InventoryItemId = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerPreferredPowders", x => x.Id);
table.ForeignKey(
name: "FK_CustomerPreferredPowders_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CustomerPreferredPowders_InventoryItems_InventoryItemId",
column: x => x.InventoryItemId,
principalTable: "InventoryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
migrationBuilder.CreateIndex(
name: "IX_CustomerPreferredPowders_CustomerId_InventoryItemId",
table: "CustomerPreferredPowders",
columns: new[] { "CustomerId", "InventoryItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CustomerPreferredPowders_InventoryItemId",
table: "CustomerPreferredPowders",
column: "InventoryItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerPreferredPowders");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
}
}
}
@@ -2944,6 +2944,58 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CustomerNotes");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", 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<int>("CustomerId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("InventoryItemId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InventoryItemId");
b.HasIndex("CustomerId", "InventoryItemId")
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
b.ToTable("CustomerPreferredPowders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
{
b.Property<int>("Id")
@@ -4269,6 +4321,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)");
@@ -4560,6 +4615,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId")
.HasColumnType("int");
@@ -7053,7 +7111,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7064,7 +7122,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7075,7 +7133,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7385,6 +7443,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ProfitPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProspectAddress")
.HasColumnType("nvarchar(max)");
@@ -9485,6 +9546,25 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
.WithMany()
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
b.Navigation("InventoryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
{
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes;
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
private IRepository<JobStatusHistory>? _jobStatusHistory;
private IRepository<PricingTier>? _pricingTiers;
@@ -321,6 +322,8 @@ public class UnitOfWork : IUnitOfWork
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
public IRepository<CustomerNote> CustomerNotes =>
_customerNotes ??= new Repository<CustomerNote>(_context);
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
public IRepository<JobStatusHistory> JobStatusHistory =>
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteApproved, values,
$"Quote {quote.QuoteNumber} Approved — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, notifType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
job.CompanyId, NotificationType.JobCompleted, values,
$"Job {job.JobNumber} Complete — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
""";
}
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = !string.IsNullOrEmpty(paymentUrl)
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml);
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReceived, values,
$"Payment Received — Invoice {invoice.InvoiceNumber}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReminder, values,
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, notificationType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
/// <summary>
/// Appends CAN-SPAM required footer as HTML.
/// </summary>
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
{
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
if (!hasUnsubscribeUrl && !hasAddress)
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
return htmlBody;
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
if (hasReplyTo)
{
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
}
if (hasAddress)
{
var addressLine = BuildAddressLine(company!);
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
var email = prefs?.EmailFromAddress;
var name = prefs?.EmailFromName;
if (string.IsNullOrWhiteSpace(email))
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
else
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
return (email, name);
}
/// <summary>
@@ -144,9 +144,11 @@ public class CustomersController : Controller
}
/// <summary>
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
/// Credit memos are loaded separately (not via eager loading) because the customer entity
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
/// Renders the customer detail page. In addition to basic info and credit memos, runs
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
/// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
/// </summary>
public async Task<IActionResult> Details(int? id)
{
@@ -170,6 +172,115 @@ public class CustomersController : Controller
.Take(10)
.ToList();
// CRM queries — must be sequential; EF Core's DbContext is not thread-safe
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();
var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
j => j.CustomerId == id.Value && j.CompanyId == companyId
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
false, j => j.JobStatus))
.OrderBy(j => j.UpdatedAt)
.ToList();
ViewBag.PendingPickups = pendingPickups;
var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
.OrderByDescending(n => n.CreatedAt)
.ToList();
ViewBag.CustomerNotes = customerNotes;
var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id.Value, false, p => p.InventoryItem))
.ToList();
ViewBag.PreferredPowders = preferredPowders;
// Stats
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
var stats = new CustomerLifetimeStatsDto
{
TotalJobs = jobs.Count,
ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
TotalRevenue = nonVoided.Sum(i => i.Total),
TotalCollected = nonVoided.Sum(i => i.AmountPaid),
AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
TotalQuotes = quotes.Count,
TotalInvoices = invoices.Count,
OpenBalance = customer.CurrentBalance
};
stats.DaysSinceLastJob = stats.LastJobDate.HasValue
? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
: null;
// Timeline: merge all event types, sort descending, cap at 15
var events = new List<CustomerTimelineEventDto>();
foreach (var j in jobs)
events.Add(new CustomerTimelineEventDto
{
Date = j.CreatedAt,
Icon = "bi-briefcase",
BadgeColor = "primary",
Title = $"Job {j.JobNumber}",
Subtitle = j.Description,
Amount = j.FinalPrice > 0 ? j.FinalPrice : null,
EntityId = j.Id,
LinkController = "Jobs",
LinkAction = "Details"
});
foreach (var q in quotes)
events.Add(new CustomerTimelineEventDto
{
Date = q.QuoteDate,
Icon = "bi-file-text",
BadgeColor = "info",
Title = $"Quote {q.QuoteNumber}",
Subtitle = q.QuoteStatus?.DisplayName,
Amount = q.Total > 0 ? q.Total : null,
EntityId = q.Id,
LinkController = "Quotes",
LinkAction = "Details"
});
foreach (var inv in invoices)
events.Add(new CustomerTimelineEventDto
{
Date = inv.InvoiceDate,
Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
Title = $"Invoice {inv.InvoiceNumber}",
Subtitle = inv.Status.ToString(),
Amount = inv.Total,
EntityId = inv.Id,
LinkController = "Invoices",
LinkAction = "Details"
});
foreach (var dep in deposits)
events.Add(new CustomerTimelineEventDto
{
Date = dep.ReceivedDate,
Icon = "bi-cash-coin",
BadgeColor = "success",
Title = "Deposit received",
Subtitle = dep.ReceiptNumber,
Amount = dep.Amount,
EntityId = dep.JobId,
LinkController = dep.JobId.HasValue ? "Jobs" : null,
LinkAction = dep.JobId.HasValue ? "Details" : null
});
ViewBag.CrmStats = stats;
ViewBag.Timeline = events
.OrderByDescending(e => e.Date)
.Take(15)
.ToList();
var customerDto = _mapper.Map<CustomerDto>(customer);
return View(customerDto);
}
@@ -938,6 +1049,166 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Adds a quick internal note to the customer record. Returns the rendered note HTML so
/// the caller can prepend it to the notes list without a full page reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
{
if (string.IsNullOrWhiteSpace(note))
return Json(new { success = false, message = "Note cannot be empty." });
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var currentUser = await _userManager.GetUserAsync(User);
var entity = new PowderCoating.Core.Entities.CustomerNote
{
CustomerId = id,
Note = note.Trim(),
IsImportant = isImportant,
CreatedBy = currentUser?.Email
};
await _unitOfWork.CustomerNotes.AddAsync(entity);
await _unitOfWork.CompleteAsync();
var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
var author = currentUser?.Email ?? "Staff";
var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
<div class=""flex-grow-1"">
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">&#9733;</span>" : "")}
<span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
<div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} &mdash; {displayDate}</div>
</div>
<button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
<i class=""bi bi-trash""></i>
</button>
</div>";
return Json(new { success = true, noteHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding note to customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Soft-deletes a single customer note. Only the owning company can delete their own notes
/// (enforced via CompanyId on the entity + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteCustomerNote(int id, int noteId)
{
try
{
var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId);
if (note == null || note.CustomerId != id)
return Json(new { success = false, message = "Note not found." });
await _unitOfWork.CustomerNotes.SoftDeleteAsync(note);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead.
/// Results are scoped to the current company and only include active items.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchInventoryItems(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(Array.Empty<object>());
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var lower = term.ToLower();
var items = (await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.IsActive
&& (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower)))))
.OrderBy(i => i.Name)
.Take(10)
.Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU })
.ToList();
return Json(items);
}
/// <summary>
/// Associates an inventory item as a preferred powder for a customer.
/// Silently succeeds if the association already exists (idempotent).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddPreferredPowder(int id, int inventoryItemId, string? notes = null)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault();
if (existing != null)
return Json(new { success = false, message = $"{item.Name} is already in preferred powders." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder
{
CustomerId = id,
InventoryItemId = inventoryItemId,
Notes = notes?.Trim(),
CompanyId = companyId
});
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Removes a preferred-powder association by inventory item ID. Soft-deletes the record
/// so the history is preserved but it no longer appears on the customer page.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemovePreferredPowder(int id, int itemId)
{
try
{
var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault();
if (record == null) return Json(new { success = false, message = "Record not found." });
await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
dto.JobId = job.Id;
dto.CustomerId = job.CustomerId;
dto.CustomerPO = job.CustomerPO;
dto.ProjectName = job.ProjectName;
// Resolve catalog item revenue accounts for pre-population
var catalogItemIds = job.JobItems
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
InternalNotes = dto.InternalNotes,
Terms = dto.Terms,
CustomerPO = dto.CustomerPO,
ProjectName = dto.ProjectName,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
InternalNotes = invoice.InternalNotes,
Terms = invoice.Terms,
CustomerPO = invoice.CustomerPO,
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
InvoiceItems = invoice.InvoiceItems
.Where(i => !i.IsDeleted)
.OrderBy(i => i.DisplayOrder)
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
invoice.InternalNotes = dto.InternalNotes;
invoice.Terms = dto.Terms;
invoice.CustomerPO = dto.CustomerPO;
invoice.ProjectName = dto.ProjectName;
invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email;
@@ -1981,6 +1981,146 @@ public class JobsController : Controller
}
/// <summary>
/// <summary>
/// Creates a new job that is a copy of an existing job. All items, coats, and prep services
/// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem)
/// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links
/// are cleared; status resets to Pending so the job enters the normal workflow from the start.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> CloneJob(int id)
{
try
{
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
if (source == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
if (pendingStatus == null)
{
this.ToastError("Could not find Pending status for this company.");
return RedirectToAction(nameof(Details), new { id });
}
var newJob = new Job
{
JobNumber = await GenerateJobNumber(),
CustomerId = source.CustomerId,
CompanyId = companyId,
JobStatusId = pendingStatus.Id,
JobPriorityId = source.JobPriorityId,
Description = source.Description,
CustomerPO = source.CustomerPO,
ProjectName = source.ProjectName,
SpecialInstructions = source.SpecialInstructions,
InternalNotes = source.InternalNotes,
Tags = source.Tags,
IsRushJob = source.IsRushJob,
RequiresCustomerApproval = source.RequiresCustomerApproval,
DiscountType = source.DiscountType,
DiscountValue = source.DiscountValue,
DiscountReason = source.DiscountReason,
OvenCostId = source.OvenCostId,
OvenBatches = source.OvenBatches,
OvenCycleMinutes = source.OvenCycleMinutes,
ShopSuppliesPercent = source.ShopSuppliesPercent,
ShopAccessCode = Guid.NewGuid()
};
await _unitOfWork.Jobs.AddAsync(newJob);
await _unitOfWork.CompleteAsync();
foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted))
{
var newItem = new JobItem
{
JobId = newJob.Id,
CompanyId = companyId,
Description = srcItem.Description,
Quantity = srcItem.Quantity,
ColorName = srcItem.ColorName,
ColorCode = srcItem.ColorCode,
Finish = srcItem.Finish,
SurfaceArea = srcItem.SurfaceArea,
SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt,
CatalogItemId = srcItem.CatalogItemId,
UnitPrice = srcItem.UnitPrice,
TotalPrice = srcItem.TotalPrice,
LaborCost = srcItem.LaborCost,
IsGenericItem = srcItem.IsGenericItem,
ManualUnitPrice = srcItem.ManualUnitPrice,
PowderCostOverride = srcItem.PowderCostOverride,
IsLaborItem = srcItem.IsLaborItem,
IsSalesItem = srcItem.IsSalesItem,
IsAiItem = srcItem.IsAiItem,
AiTags = srcItem.AiTags,
IsCustomFormulaItem = srcItem.IsCustomFormulaItem,
CustomItemTemplateId = srcItem.CustomItemTemplateId,
FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson,
Sku = srcItem.Sku,
IncludePrepCost = srcItem.IncludePrepCost,
RequiresSandblasting = srcItem.RequiresSandblasting,
RequiresMasking = srcItem.RequiresMasking,
EstimatedMinutes = srcItem.EstimatedMinutes,
Complexity = srcItem.Complexity,
Notes = srcItem.Notes
// AiPredictionId intentionally not copied — prediction belongs to original quote
};
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted))
{
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = newItem.Id,
CompanyId = companyId,
CoatName = srcCoat.CoatName,
Sequence = srcCoat.Sequence,
InventoryItemId = srcCoat.InventoryItemId,
ColorName = srcCoat.ColorName,
VendorId = srcCoat.VendorId,
ColorCode = srcCoat.ColorCode,
Finish = srcCoat.Finish,
CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb,
TransferEfficiency = srcCoat.TransferEfficiency,
PowderCostPerLb = srcCoat.PowderCostPerLb,
PowderToOrder = srcCoat.PowderToOrder,
NoExtraLayerCharge = srcCoat.NoExtraLayerCharge,
Notes = srcCoat.Notes
// Powder ordering / receiving tracking fields intentionally not copied
});
}
foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted))
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = newItem.Id,
CompanyId = companyId,
PrepServiceId = srcPrep.PrepServiceId,
EstimatedMinutes = srcPrep.EstimatedMinutes,
BlastSetupId = srcPrep.BlastSetupId
});
}
}
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Job cloned as {newJob.JobNumber} &mdash; review and update dates before scheduling.");
return RedirectToAction(nameof(Details), new { id = newJob.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cloning job {JobId}", id);
this.ToastError("An error occurred while cloning the job.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
/// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter —
/// this prevents number reuse if a job is deleted after being created this month.
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
// Update quote to link to new customer
// Update quote to link to new customer.
// Do NOT set "Converted" status here — that status is reserved for when a job is
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
// user immediately click "Create Job from Quote" on the next screen.
quote.CustomerId = customer.Id;
// Clear prospect fields
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
return RedirectToAction("Details", "Customers", new { id = customer.Id });
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
}
catch (Exception ex)
{
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
Total = quote.Total
}),
CustomerPO = quote.CustomerPO,
ProjectName = quote.ProjectName,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
IsRushJob = quote.IsRushJob,
@@ -328,6 +328,121 @@
</div>
</div>
}
<!-- Customer Notes -->
@{
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-sticky me-2 text-primary"></i>Internal Notes
</h5>
</div>
<div class="card-body p-0">
<div id="customer-notes-list">
@if (customerNotes != null && customerNotes.Count > 0)
{
@foreach (var note in customerNotes)
{
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
<div class="flex-grow-1">
@if (note.IsImportant)
{
<span class="text-warning me-1" title="Important">&#9733;</span>
}
<span class="note-text small">@note.Note</span>
<div class="text-muted" style="font-size:0.75rem;">
@(note.CreatedBy ?? "Staff") &mdash; @note.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy h:mm tt")
</div>
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0 align-self-start"
onclick="deleteCustomerNote(@Model.Id, @note.Id)" title="Delete note">
<i class="bi bi-trash"></i>
</button>
</div>
}
}
else
{
<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>
}
</div>
<div class="px-3 py-3 border-top bg-light">
<div class="mb-2">
<textarea id="newNoteText" class="form-control form-control-sm" rows="2"
placeholder="Add an internal note..." maxlength="2000"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check form-check-sm mb-0">
<input class="form-check-input" type="checkbox" id="newNoteImportant">
<label class="form-check-label small" for="newNoteImportant">
<span class="text-warning">&#9733;</span> Mark important
</label>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addCustomerNote(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Note
</button>
</div>
</div>
</div>
</div>
<!-- Recent Activity Timeline -->
@{
var timeline = ViewBag.Timeline as List<PowderCoating.Application.DTOs.Customer.CustomerTimelineEventDto>;
}
@if (timeline != null && timeline.Count > 0)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Recent Activity
</h5>
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
@foreach (var ev in timeline)
{
var hasLink = ev.LinkController != null && ev.EntityId.HasValue;
var rowTag = hasLink ? "a" : "div";
var href = hasLink
? Url.Action(ev.LinkAction, ev.LinkController, new { id = ev.EntityId })
: null;
<div class="d-flex align-items-start gap-3 px-3 py-3 border-bottom @(hasLink ? "timeline-row" : "")">
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 mt-1"
style="width:34px;height:34px;background:var(--bs-@(ev.BadgeColor)-bg-subtle,#f0f0f0);">
<i class="bi @ev.Icon text-@ev.BadgeColor" style="font-size:0.9rem;"></i>
</div>
<div class="flex-grow-1 min-width-0">
@if (hasLink)
{
<a asp-controller="@ev.LinkController" asp-action="@ev.LinkAction" asp-route-id="@ev.EntityId"
class="fw-semibold text-decoration-none text-body d-block text-truncate">@ev.Title</a>
}
else
{
<span class="fw-semibold d-block text-truncate">@ev.Title</span>
}
@if (!string.IsNullOrEmpty(ev.Subtitle))
{
<span class="text-muted small d-block text-truncate">@ev.Subtitle</span>
}
<span class="text-muted" style="font-size:0.75rem;">@ev.Date.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</span>
</div>
@if (ev.Amount.HasValue)
{
<div class="text-end flex-shrink-0">
<span class="fw-semibold small">@ev.Amount.Value.ToString("C")</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
<!-- Right Column - Statistics -->
@@ -378,6 +493,41 @@
</div>
</div>
<!-- Outstanding Pickups -->
@{
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
}
@if (pendingPickups != null && pendingPickups.Count > 0)
{
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
<h5 class="mb-0 fw-semibold text-warning-emphasis">
<i class="bi bi-truck me-2"></i>Ready for Pickup
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
</h5>
</div>
<div class="card-body p-0">
@foreach (var pickup in pendingPickups)
{
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
<div class="flex-grow-1">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
@if (!string.IsNullOrEmpty(pickup.Description))
{
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
}
</div>
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
</span>
</div>
}
</div>
</div>
}
<!-- Store Credit History -->
@{
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
@@ -430,33 +580,146 @@
</div>
}
<!-- Activity -->
<!-- Customer Stats -->
@{
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStats != null)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Activity
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Last Contact</label>
<p class="mb-0">
@if (Model.LastContactDate.HasValue)
<!-- Jobs row -->
<div class="row g-2 mb-3">
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
<div class="text-muted small mb-1">Total Jobs</div>
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
@if (crmStats.ActiveJobs > 0)
{
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
<span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
@crmStats.ActiveJobs active
</span>
}
</div>
<div class="col-6 text-center p-2">
<div class="text-muted small mb-1">Avg Job Value</div>
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
</div>
</div>
<hr class="my-2" />
<!-- Revenue row -->
<div class="row g-2 mb-2">
<div class="col-6">
<div class="text-muted small mb-1">Lifetime Revenue</div>
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
</div>
<div class="col-6">
<div class="text-muted small mb-1">Total Collected</div>
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
</div>
</div>
<hr class="my-2" />
<!-- Footer stats -->
<div class="d-flex justify-content-between text-muted small mt-2">
<span>
@if (crmStats.DaysSinceLastJob.HasValue)
{
<i class="bi bi-calendar-check me-1"></i>
@if (crmStats.DaysSinceLastJob == 0)
{
<span>Last job today</span>
}
else
{
<span class="text-muted">No contact recorded</span>
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
}
</p>
}
else
{
<span>No jobs yet</span>
}
</span>
<span>
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
</span>
</div>
<div>
<label class="text-muted small mb-1">Customer Since</label>
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
<div class="text-muted small mt-1">
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
</div>
</div>
</div>
}
<!-- Preferred Powders -->
@{
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
</h5>
</div>
<div class="card-body p-0">
<div id="preferred-powders-list">
@if (preferredPowders != null && preferredPowders.Count > 0)
{
@foreach (var p in preferredPowders)
{
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1">
<span class="small fw-semibold">@p.InventoryItem.Name</span>
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
{
<span class="text-muted small ms-1">&mdash; @p.InventoryItem.ColorName</span>
}
@if (!string.IsNullOrEmpty(p.Notes))
{
<div class="text-muted" style="font-size:0.75rem;">@p.Notes</div>
}
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0"
onclick="removePreferredPowder(@Model.Id, @p.InventoryItemId)"
title="Remove from preferred">&times;</button>
</div>
}
}
else
{
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
}
</div>
<div class="px-3 py-3 border-top bg-light position-relative">
<div class="mb-2">
<input type="text" id="powderSearchInput" class="form-control form-control-sm"
placeholder="Search powder by name or SKU..."
oninput="searchInventoryItems(this.value)"
autocomplete="off" />
<input type="hidden" id="selectedPowderId" />
<div id="powderSearchResults" class="dropdown-menu w-100 show p-0"
style="display:none!important;position:absolute;z-index:1000;"
onfocusout=""></div>
</div>
<div class="mb-2">
<input type="text" id="powderNotes" class="form-control form-control-sm"
placeholder="Optional notes (e.g. &quot;customer prefers this for wheels&quot;)"
maxlength="500" />
</div>
<button type="button" class="btn btn-sm btn-primary w-100"
onclick="addPreferredPowder(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Powder
</button>
</div>
</div>
</div>
<style>
#powderSearchResults:not(:empty) { display:block!important; max-height:200px; overflow-y:auto; }
</style>
<!-- Quick Actions -->
<div class="card border-0 shadow-sm">
@@ -482,6 +745,17 @@
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
@{
var crmStatsForActions = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStatsForActions?.LastJobId != null)
{
<a asp-controller="Jobs" asp-action="CloneJob" asp-route-id="@crmStatsForActions.LastJobId"
class="btn btn-outline-secondary"
title="Create a new job pre-filled with the last job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
</a>
}
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
<i class="bi bi-file-text me-2"></i>New Quote
</a>
@@ -44,11 +44,21 @@
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
title="Click to filter list to low stock items">
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
style="cursor:pointer;transition:box-shadow .15s;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
<p class="text-muted mb-1" style="font-size: 0.875rem;">
Low Stock Items
@if (lowStockCount > 0)
{
<i class="bi bi-funnel-fill ms-1 text-danger" style="font-size:.7rem;" title="Click to filter"></i>
}
</p>
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #fee2e2;">
@@ -57,6 +67,7 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
@@ -102,11 +113,13 @@
<div class="stat-value">@Model.TotalCount</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
<div class="stat-item" style="cursor:pointer;">
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
<div class="stat-label">Low Stock</div>
</div>
</a>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
<div class="stat-value">@activeCount</div>
@@ -170,6 +170,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<div class="d-flex align-items-center gap-1">
@@ -193,6 +193,13 @@
<p class="mb-0">@Model.CustomerPO</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project Name</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
{
<div class="col-md-6">
@@ -62,6 +62,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
@@ -124,6 +124,10 @@
</div>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7">
<div class="d-flex align-items-center gap-1">
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
@@ -19,6 +19,10 @@
title="Save this job as a reusable template">
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
</button>
<a asp-action="CloneJob" asp-route-id="@Model.Id" class="btn btn-outline-secondary"
title="Create a new job pre-filled with this job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Clone Job
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>
@@ -172,6 +176,13 @@
<label class="text-muted small mb-1">Customer PO</label>
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
</div>
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
<div class="col-12">
<label class="text-muted small mb-1">Description</label>
<p class="mb-0">@Model.Description</p>
@@ -101,6 +101,10 @@
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7">
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
@@ -357,6 +357,13 @@
<div class="info-value">@Model.CustomerPO</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="info-row">
<div class="info-label">Project</div>
<div class="info-value">@Model.ProjectName</div>
</div>
}
</div>
<div class="col-6">
<div class="section-title">
@@ -187,6 +187,12 @@
<input asp-for="CustomerPO" class="form-control" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Notes" class="form-label"></label>
@@ -183,6 +183,10 @@
{
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
}
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<p><strong>Project:</strong> @Model.ProjectName</p>
}
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Description))
@@ -150,6 +150,12 @@
<input asp-for="CustomerPO" class="form-control" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Notes" class="form-label"></label>
@@ -38,6 +38,148 @@ async function cancelSmsConsent() {
}
}
// ── Customer Notes ────────────────────────────────────────────────────────────
async function addCustomerNote(customerId) {
const textarea = document.getElementById('newNoteText');
const importantCb = document.getElementById('newNoteImportant');
const note = textarea?.value?.trim();
if (!note) { toastr.warning('Please enter a note.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('customer-notes-list');
const placeholder = document.getElementById('no-notes-placeholder');
if (placeholder) placeholder.remove();
list.insertAdjacentHTML('afterbegin', data.noteHtml);
textarea.value = '';
if (importantCb) importantCb.checked = false;
toastr.success('Note added.');
} else {
toastr.error(data.message || 'Could not add note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function deleteCustomerNote(customerId, noteId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `noteId=${noteId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
const list = document.getElementById('customer-notes-list');
if (list && list.querySelectorAll('.customer-note-item').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
} else {
toastr.error(data.message || 'Could not delete note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
// ── Preferred Powders ─────────────────────────────────────────────────────────
let _powderSearchTimer = null;
function searchInventoryItems(term) {
clearTimeout(_powderSearchTimer);
const dropdown = document.getElementById('powderSearchResults');
if (!term || term.length < 2) { if (dropdown) dropdown.innerHTML = ''; return; }
_powderSearchTimer = setTimeout(async () => {
try {
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
const data = await res.json();
if (!dropdown) return;
dropdown.innerHTML = data.length === 0
? '<div class="dropdown-item text-muted small">No results</div>'
: data.map(i => `<button type="button" class="dropdown-item small"
onclick="selectPowder(${i.id}, ${JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : ''))})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku}</span></button>`).join('');
} catch { /* silent */ }
}, 300);
}
function selectPowder(itemId, label) {
document.getElementById('selectedPowderId').value = itemId;
document.getElementById('powderSearchInput').value = label;
const dropdown = document.getElementById('powderSearchResults');
if (dropdown) dropdown.innerHTML = '';
}
async function addPreferredPowder(customerId) {
const itemId = document.getElementById('selectedPowderId')?.value;
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `inventoryItemId=${itemId}&notes=${encodeURIComponent(notes)}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('preferred-powders-list');
const placeholder = document.getElementById('no-powders-placeholder');
if (placeholder) placeholder.remove();
const notesHtml = data.notes ? `<div class="text-muted" style="font-size:0.75rem;">${data.notes}</div>` : '';
list.insertAdjacentHTML('beforeend',
`<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="${data.itemId}">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1"><span class="small fw-semibold">${data.itemName}</span>${notesHtml}</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0"
onclick="removePreferredPowder(${customerId}, ${data.itemId})" title="Remove">&times;</button>
</div>`);
document.getElementById('powderSearchInput').value = '';
document.getElementById('selectedPowderId').value = '';
if (document.getElementById('powderNotes')) document.getElementById('powderNotes').value = '';
toastr.success(`${data.itemName} added to preferred powders.`);
} else {
toastr.warning(data.message || 'Could not add powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function removePreferredPowder(customerId, itemId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/RemovePreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `itemId=${itemId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-powder-id="${itemId}"]`)?.remove();
const list = document.getElementById('preferred-powders-list');
if (list && list.querySelectorAll('[data-powder-id]').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>');
} else {
toastr.error(data.message || 'Could not remove powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section');
if (!section) return;
@@ -0,0 +1,378 @@
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Customer;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
using System.Security.Claims;
namespace PowderCoating.UnitTests;
public class CustomersControllerCrmTests
{
// ── Details — guard ───────────────────────────────────────────────────
[Fact]
public async Task Details_WhenCustomerNotFound_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 999);
Assert.IsType<NotFoundResult>(result);
}
// ── Details — zero-history customer ───────────────────────────────────
[Fact]
public async Task Details_WithNoHistory_ProducesZeroStats()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(0, stats.TotalJobs);
Assert.Equal(0, stats.TotalQuotes);
Assert.Equal(0m, stats.TotalRevenue);
Assert.Equal(0m, stats.AverageJobValue); // no divide-by-zero
Assert.Null(stats.DaysSinceLastJob);
}
[Fact]
public async Task Details_WithNoHistory_DoesNotRenderTimeline()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Empty(timeline);
}
// ── Details — stats calculation ───────────────────────────────────────
[Fact]
public async Task Details_WithJobsAndInvoices_CalculatesStatsCorrectly()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var activeStatus = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 300m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 700m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 300m, amountPaid: 300m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 700m, amountPaid: 400m, status: InvoiceStatus.PartiallyPaid);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(2, stats.TotalJobs);
Assert.Equal(2, stats.ActiveJobs);
Assert.Equal(2, stats.TotalInvoices);
Assert.Equal(1000m, stats.TotalRevenue);
Assert.Equal(700m, stats.TotalCollected);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Details — voided invoices excluded ────────────────────────────────
[Fact]
public async Task Details_VoidedInvoicesExcludedFromRevenueAndCollected()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m);
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 500m, amountPaid: 500m, status: InvoiceStatus.Paid);
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 999m, amountPaid: 0m, status: InvoiceStatus.Voided);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(500m, stats.TotalRevenue); // voided invoice excluded
Assert.Equal(500m, stats.TotalCollected); // voided invoice excluded
Assert.Equal(2, stats.TotalInvoices); // count includes voided (informational)
}
// ── Details — active-job count excludes terminal statuses ─────────────
[Fact]
public async Task Details_ActiveJobsExcludesTerminalStatuses()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var active = SeedJobStatus(context, id: 1, isTerminal: false);
var completed = SeedJobStatus(context, id: 2, isTerminal: true);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: active.Id, finalPrice: 100m);
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 200m);
SeedJob(context, id: 3, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 300m);
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(3, stats.TotalJobs);
Assert.Equal(1, stats.ActiveJobs);
}
// ── Details — timeline cap and sort ───────────────────────────────────
[Fact]
public async Task Details_TimelineCappedAt15Events()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
for (int i = 1; i <= 18; i++)
{
context.Jobs.Add(new Job
{
Id = i,
CompanyId = 1,
CustomerId = 1,
JobStatusId = status.Id,
JobNumber = $"JOB-0001-{i:D4}",
Description = $"Job {i}",
FinalPrice = 100m,
CreatedAt = new DateTime(2026, 1, i, 0, 0, 0, DateTimeKind.Utc)
});
}
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(15, timeline.Count);
}
[Fact]
public async Task Details_TimelineIsSortedNewestFirst()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
// Use Invoice.InvoiceDate for timeline dates — SaveChangesAsync stamps CreatedAt but
// does not touch InvoiceDate, so we can seed distinct values that survive the save.
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 100m, amountPaid: 100m,
status: InvoiceStatus.Paid, invoiceDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 200m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
SeedInvoice(context, id: 3, customerId: 1, companyId: 1, total: 300m, amountPaid: 0m,
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc));
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
Assert.NotNull(timeline);
Assert.Equal(3, timeline.Count);
Assert.True(timeline[0].Date > timeline[1].Date, "First event should be the newest");
Assert.True(timeline[1].Date > timeline[2].Date, "Events should be descending");
}
// ── Details — tenant isolation ────────────────────────────────────────
[Fact]
public async Task Details_DoesNotIncludeJobsFromOtherCompanies()
{
await using var context = CreateContext();
SeedCustomer(context, id: 1, companyId: 1);
var status = SeedJobStatus(context, id: 1, isTerminal: false);
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m); // this company
SeedJob(context, id: 2, customerId: 1, companyId: 2, statusId: status.Id, finalPrice: 999m); // other company
await context.SaveChangesAsync();
var controller = CreateController(context, companyId: 1);
var result = await controller.Details(id: 1);
var view = Assert.IsType<ViewResult>(result);
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
Assert.NotNull(stats);
Assert.Equal(1, stats.TotalJobs);
Assert.Equal(500m, stats.AverageJobValue);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static CustomersController CreateController(ApplicationDbContext context, int companyId)
{
var uow = new UnitOfWork(context);
// Tests cover CrmStats/Timeline logic, not DTO mapping — stub mapper to return a valid model
var mapperMock = new Mock<IMapper>();
mapperMock
.Setup(m => m.Map<CustomerDto>(It.IsAny<Customer>()))
.Returns((Customer c) => new CustomerDto { Id = c.Id, CompanyName = c.CompanyName, IsActive = c.IsActive });
var mapper = mapperMock.Object;
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
var controller = new CustomersController(
uow,
mapper,
Mock.Of<ILogger<CustomersController>>(),
Mock.Of<INotificationService>(),
Mock.Of<ISubscriptionService>(),
tenantContext.Object,
CreateUserManagerMock().Object,
Mock.Of<IFinancialReportService>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
return controller;
}
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId)
{
context.Customers.Add(new Customer
{
Id = id,
CompanyId = companyId,
CompanyName = $"Test Customer {id}",
IsActive = true,
CreatedAt = DateTime.UtcNow
});
}
private static JobStatusLookup SeedJobStatus(ApplicationDbContext context, int id, bool isTerminal)
{
var status = new JobStatusLookup
{
Id = id,
StatusCode = isTerminal ? "COMPLETED" : "IN_PROGRESS",
DisplayName = isTerminal ? "Completed" : "In Progress",
DisplayOrder = id,
IsTerminalStatus = isTerminal
};
context.JobStatusLookups.Add(status);
return status;
}
private static void SeedJob(
ApplicationDbContext context, int id, int customerId, int companyId, int statusId, decimal finalPrice)
{
context.Jobs.Add(new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = finalPrice,
CreatedAt = DateTime.UtcNow
});
}
private static Job MakeJob(int id, int customerId, int companyId, int statusId, DateTime date) =>
new Job
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
JobStatusId = statusId,
JobNumber = $"JOB-0001-{id:D4}",
Description = $"Job {id}",
FinalPrice = 100m,
CreatedAt = date
};
private static void SeedInvoice(
ApplicationDbContext context, int id, int customerId, int companyId,
decimal total, decimal amountPaid, InvoiceStatus status,
DateTime? invoiceDate = null)
{
context.Invoices.Add(new Invoice
{
Id = id,
CompanyId = companyId,
CustomerId = customerId,
InvoiceNumber = $"INV-0001-{id:D4}",
InvoiceDate = invoiceDate ?? DateTime.UtcNow,
Total = total,
AmountPaid = amountPaid,
Status = status
});
}
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
// SuperAdmin principal: bypasses the CompanyId global query filter so all
// seeded rows are visible, matching the same approach in DepositsControllerTests.
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}