Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 711cd01cd3 |
@@ -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!;
|
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 class JobStatusHistory : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IJobPhotoRepository JobPhotos { get; }
|
IJobPhotoRepository JobPhotos { get; }
|
||||||
IRepository<JobNote> JobNotes { get; }
|
IRepository<JobNote> JobNotes { get; }
|
||||||
IRepository<CustomerNote> CustomerNotes { get; }
|
IRepository<CustomerNote> CustomerNotes { get; }
|
||||||
|
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||||
IRepository<PricingTier> PricingTiers { get; }
|
IRepository<PricingTier> PricingTiers { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<JobNote> JobNotes { get; set; }
|
public DbSet<JobNote> JobNotes { get; set; }
|
||||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
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>
|
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
/// <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));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
@@ -1719,6 +1723,23 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_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
|
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
Generated
+11239
File diff suppressed because it is too large
Load Diff
+110
@@ -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");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -7059,7 +7111,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7070,7 +7122,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7081,7 +7133,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -9494,6 +9546,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Customer");
|
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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IJobPhotoRepository? _jobPhotos;
|
private IJobPhotoRepository? _jobPhotos;
|
||||||
private IRepository<JobNote>? _jobNotes;
|
private IRepository<JobNote>? _jobNotes;
|
||||||
private IRepository<CustomerNote>? _customerNotes;
|
private IRepository<CustomerNote>? _customerNotes;
|
||||||
|
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||||
private IRepository<PricingTier>? _pricingTiers;
|
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>
|
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<CustomerNote> CustomerNotes =>
|
public IRepository<CustomerNote> CustomerNotes =>
|
||||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
_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>
|
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||||
|
|||||||
@@ -144,9 +144,11 @@ public class CustomersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
/// Renders the customer detail page. In addition to basic info and credit memos, runs
|
||||||
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
|
||||||
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
/// (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>
|
/// </summary>
|
||||||
public async Task<IActionResult> Details(int? id)
|
public async Task<IActionResult> Details(int? id)
|
||||||
{
|
{
|
||||||
@@ -170,6 +172,115 @@ public class CustomersController : Controller
|
|||||||
.Take(10)
|
.Take(10)
|
||||||
.ToList();
|
.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);
|
var customerDto = _mapper.Map<CustomerDto>(customer);
|
||||||
return View(customerDto);
|
return View(customerDto);
|
||||||
}
|
}
|
||||||
@@ -938,6 +1049,166 @@ public class CustomersController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
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"">★</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)} — {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>
|
/// <summary>
|
||||||
/// Displays or downloads a dated activity statement for a customer.
|
/// 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.
|
/// 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>
|
||||||
|
/// <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} — 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).
|
/// 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 —
|
/// 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.
|
/// this prevents number reuse if a job is deleted after being created this month.
|
||||||
|
|||||||
@@ -328,6 +328,121 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">★</span>
|
||||||
|
}
|
||||||
|
<span class="note-text small">@note.Note</span>
|
||||||
|
<div class="text-muted" style="font-size:0.75rem;">
|
||||||
|
@(note.CreatedBy ?? "Staff") — @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">★</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>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column - Statistics -->
|
<!-- Right Column - Statistics -->
|
||||||
@@ -378,6 +493,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Store Credit History -->
|
||||||
@{
|
@{
|
||||||
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
||||||
@@ -430,33 +580,146 @@
|
|||||||
</div>
|
</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 border-0 shadow-sm mb-4">
|
||||||
<div class="card-header bg-white border-0 py-3">
|
<div class="card-header bg-white border-0 py-3">
|
||||||
<h5 class="mb-0 fw-semibold">
|
<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>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<!-- Jobs row -->
|
||||||
<label class="text-muted small mb-1">Last Contact</label>
|
<div class="row g-2 mb-3">
|
||||||
<p class="mb-0">
|
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
|
||||||
@if (Model.LastContactDate.HasValue)
|
<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
|
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>
|
||||||
<div>
|
<div class="text-muted small mt-1">
|
||||||
<label class="text-muted small mb-1">Customer Since</label>
|
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
|
||||||
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">— @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">×</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. "customer prefers this for wheels")"
|
||||||
|
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 -->
|
<!-- Quick Actions -->
|
||||||
<div class="card border-0 shadow-sm">
|
<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">
|
<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
|
<i class="bi bi-plus-circle me-2"></i>New Job
|
||||||
</a>
|
</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'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">
|
<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
|
<i class="bi bi-file-text me-2"></i>New Quote
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
title="Save this job as a reusable template">
|
title="Save this job as a reusable template">
|
||||||
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
|
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
|
||||||
</button>
|
</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'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">
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
||||||
<i class="bi bi-pencil me-2"></i>Edit
|
<i class="bi bi-pencil me-2"></i>Edit
|
||||||
</a>
|
</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}¬es=${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">×</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 () {
|
window.updateCustomerSmsStatus = function () {
|
||||||
const section = document.getElementById('sms-status-section');
|
const section = document.getElementById('sms-status-section');
|
||||||
if (!section) return;
|
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user