Compare commits

...

1 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
14 changed files with 12725 additions and 22 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; }
}
@@ -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,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")
@@ -7059,7 +7111,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7070,7 +7122,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7081,7 +7133,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478),
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -9494,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 =>
@@ -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.
@@ -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.
@@ -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-bar-chart-line me-2 text-primary"></i>Customer Stats
</h5>
</div>
<div class="card-body">
<!-- 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 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>Last job @crmStats.DaysSinceLastJob days ago</span>
}
}
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 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-clock-history me-2 text-primary"></i>Activity
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
</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)
<div class="card-body p-0">
<div id="preferred-powders-list">
@if (preferredPowders != null && preferredPowders.Count > 0)
{
@foreach (var p in preferredPowders)
{
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
<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
{
<span class="text-muted">No contact recorded</span>
}
</p>
}
else
{
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
}
</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="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>
@@ -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>
@@ -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!);
}
}