Compare commits
7 Commits
03b425a12f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 |
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||
public class CustomerTimelineEventDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string BadgeColor { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? LinkController { get; set; }
|
||||
public string? LinkAction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||
public class CustomerLifetimeStatsDto
|
||||
{
|
||||
public int TotalJobs { get; set; }
|
||||
public int ActiveJobs { get; set; }
|
||||
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||
public decimal TotalRevenue { get; set; }
|
||||
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||
public decimal TotalCollected { get; set; }
|
||||
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||
public decimal AverageJobValue { get; set; }
|
||||
public DateTime? LastJobDate { get; set; }
|
||||
public int? DaysSinceLastJob { get; set; }
|
||||
public int TotalQuotes { get; set; }
|
||||
public int TotalInvoices { get; set; }
|
||||
public decimal OpenBalance { get; set; }
|
||||
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||
public int? LastJobId { get; set; }
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int? SalesTaxAccountId { get; set; }
|
||||
public string? SalesTaxAccountName { get; set; }
|
||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobDto
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
@@ -114,6 +115,7 @@ public class JobListDto
|
||||
public string? CustomerEmail { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -167,6 +169,7 @@ public class CreateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -252,6 +255,7 @@ public class UpdateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
|
||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Items
|
||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
|
||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
||||
|
||||
CreateMap<Invoice, InvoiceDto>()
|
||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||
? (s.Customer.IsCommercial
|
||||
? s.Customer.CompanyName
|
||||
|
||||
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
|
||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
|
||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
|
||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
||||
|
||||
// Additional Information
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||
public string? Tags { get; set; }
|
||||
|
||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Conversion tracking
|
||||
|
||||
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an inventory item as a preferred powder for a specific customer.
|
||||
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||
/// </summary>
|
||||
public class CustomerPreferredPowder : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class JobStatusHistory : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -43,6 +43,7 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
|
||||
@@ -230,6 +230,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<JobNote> JobNotes { get; set; }
|
||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||
@@ -551,6 +553,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
@@ -1719,6 +1723,23 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.InventoryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.InventoryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// ===================================================================
|
||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||
// ===================================================================
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260608182208_AddProjectNameToQuotesAndJobs.Designer.cs
Generated
+11165
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProjectNameToQuotesAndJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Quotes",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Jobs",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceProjectName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Invoices",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("InventoryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventoryItemId");
|
||||
|
||||
b.HasIndex("CustomerId", "InventoryItemId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
b.ToTable("CustomerPreferredPowders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4269,6 +4321,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4560,6 +4615,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7053,7 +7111,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
|
||||
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7064,7 +7122,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
|
||||
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7075,7 +7133,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
|
||||
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7385,6 +7443,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ProfitPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProspectAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -9485,6 +9546,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("InventoryItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("InventoryItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||
|
||||
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
private IRepository<PricingTier>? _pricingTiers;
|
||||
|
||||
@@ -321,6 +322,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerNote> CustomerNotes =>
|
||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
|
||||
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||
|
||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
job.CompanyId, notifType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
||||
job.CompanyId, NotificationType.JobCompleted, values,
|
||||
$"Job {job.JobNumber} Complete — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
|
||||
""";
|
||||
}
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||
: StripHtml(fullHtml);
|
||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
quote.CompanyId, notificationType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var custPlainText = StripHtml(custFullHtml);
|
||||
|
||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
||||
/// <summary>
|
||||
/// Appends CAN-SPAM required footer as HTML.
|
||||
/// </summary>
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
|
||||
{
|
||||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
|
||||
|
||||
if (!hasUnsubscribeUrl && !hasAddress)
|
||||
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
|
||||
return htmlBody;
|
||||
|
||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||
|
||||
if (hasReplyTo)
|
||||
{
|
||||
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||
}
|
||||
|
||||
if (hasAddress)
|
||||
{
|
||||
var addressLine = BuildAddressLine(company!);
|
||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
||||
var email = prefs?.EmailFromAddress;
|
||||
var name = prefs?.EmailFromName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
|
||||
else
|
||||
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
|
||||
|
||||
return (email, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -144,9 +144,11 @@ public class CustomersController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
||||
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
||||
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
||||
/// Renders the customer detail page. In addition to basic info and credit memos, runs
|
||||
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
|
||||
/// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
|
||||
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
|
||||
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
{
|
||||
@@ -170,6 +172,115 @@ public class CustomersController : Controller
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// CRM queries — must be sequential; EF Core's DbContext is not thread-safe
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
|
||||
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
|
||||
var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();
|
||||
|
||||
var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CustomerId == id.Value && j.CompanyId == companyId
|
||||
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
|
||||
false, j => j.JobStatus))
|
||||
.OrderBy(j => j.UpdatedAt)
|
||||
.ToList();
|
||||
ViewBag.PendingPickups = pendingPickups;
|
||||
|
||||
var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.ToList();
|
||||
ViewBag.CustomerNotes = customerNotes;
|
||||
|
||||
var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
|
||||
p => p.CustomerId == id.Value, false, p => p.InventoryItem))
|
||||
.ToList();
|
||||
ViewBag.PreferredPowders = preferredPowders;
|
||||
|
||||
// Stats
|
||||
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
|
||||
var stats = new CustomerLifetimeStatsDto
|
||||
{
|
||||
TotalJobs = jobs.Count,
|
||||
ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
|
||||
TotalRevenue = nonVoided.Sum(i => i.Total),
|
||||
TotalCollected = nonVoided.Sum(i => i.AmountPaid),
|
||||
AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
|
||||
LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
|
||||
LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
|
||||
TotalQuotes = quotes.Count,
|
||||
TotalInvoices = invoices.Count,
|
||||
OpenBalance = customer.CurrentBalance
|
||||
};
|
||||
stats.DaysSinceLastJob = stats.LastJobDate.HasValue
|
||||
? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
|
||||
: null;
|
||||
|
||||
// Timeline: merge all event types, sort descending, cap at 15
|
||||
var events = new List<CustomerTimelineEventDto>();
|
||||
|
||||
foreach (var j in jobs)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = j.CreatedAt,
|
||||
Icon = "bi-briefcase",
|
||||
BadgeColor = "primary",
|
||||
Title = $"Job {j.JobNumber}",
|
||||
Subtitle = j.Description,
|
||||
Amount = j.FinalPrice > 0 ? j.FinalPrice : null,
|
||||
EntityId = j.Id,
|
||||
LinkController = "Jobs",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var q in quotes)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = q.QuoteDate,
|
||||
Icon = "bi-file-text",
|
||||
BadgeColor = "info",
|
||||
Title = $"Quote {q.QuoteNumber}",
|
||||
Subtitle = q.QuoteStatus?.DisplayName,
|
||||
Amount = q.Total > 0 ? q.Total : null,
|
||||
EntityId = q.Id,
|
||||
LinkController = "Quotes",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var inv in invoices)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = inv.InvoiceDate,
|
||||
Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
|
||||
BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
|
||||
Title = $"Invoice {inv.InvoiceNumber}",
|
||||
Subtitle = inv.Status.ToString(),
|
||||
Amount = inv.Total,
|
||||
EntityId = inv.Id,
|
||||
LinkController = "Invoices",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var dep in deposits)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = dep.ReceivedDate,
|
||||
Icon = "bi-cash-coin",
|
||||
BadgeColor = "success",
|
||||
Title = "Deposit received",
|
||||
Subtitle = dep.ReceiptNumber,
|
||||
Amount = dep.Amount,
|
||||
EntityId = dep.JobId,
|
||||
LinkController = dep.JobId.HasValue ? "Jobs" : null,
|
||||
LinkAction = dep.JobId.HasValue ? "Details" : null
|
||||
});
|
||||
|
||||
ViewBag.CrmStats = stats;
|
||||
ViewBag.Timeline = events
|
||||
.OrderByDescending(e => e.Date)
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
var customerDto = _mapper.Map<CustomerDto>(customer);
|
||||
return View(customerDto);
|
||||
}
|
||||
@@ -938,6 +1049,166 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a quick internal note to the customer record. Returns the rendered note HTML so
|
||||
/// the caller can prepend it to the notes list without a full page reload.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
return Json(new { success = false, message = "Note cannot be empty." });
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var entity = new PowderCoating.Core.Entities.CustomerNote
|
||||
{
|
||||
CustomerId = id,
|
||||
Note = note.Trim(),
|
||||
IsImportant = isImportant,
|
||||
CreatedBy = currentUser?.Email
|
||||
};
|
||||
|
||||
await _unitOfWork.CustomerNotes.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
|
||||
var author = currentUser?.Email ?? "Staff";
|
||||
var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
|
||||
<div class=""flex-grow-1"">
|
||||
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">★</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>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
|
||||
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
|
||||
dto.JobId = job.Id;
|
||||
dto.CustomerId = job.CustomerId;
|
||||
dto.CustomerPO = job.CustomerPO;
|
||||
dto.ProjectName = job.ProjectName;
|
||||
|
||||
// Resolve catalog item revenue accounts for pre-population
|
||||
var catalogItemIds = job.JobItems
|
||||
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = dto.InternalNotes,
|
||||
Terms = dto.Terms,
|
||||
CustomerPO = dto.CustomerPO,
|
||||
ProjectName = dto.ProjectName,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser.Email
|
||||
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = invoice.InternalNotes,
|
||||
Terms = invoice.Terms,
|
||||
CustomerPO = invoice.CustomerPO,
|
||||
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
|
||||
InvoiceItems = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted)
|
||||
.OrderBy(i => i.DisplayOrder)
|
||||
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
|
||||
invoice.InternalNotes = dto.InternalNotes;
|
||||
invoice.Terms = dto.Terms;
|
||||
invoice.CustomerPO = dto.CustomerPO;
|
||||
invoice.ProjectName = dto.ProjectName;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
|
||||
|
||||
@@ -1981,6 +1981,146 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Creates a new job that is a copy of an existing job. All items, coats, and prep services
|
||||
/// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem)
|
||||
/// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links
|
||||
/// are cleared; status resets to Pending so the job enters the normal workflow from the start.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public async Task<IActionResult> CloneJob(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
if (source == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
|
||||
if (pendingStatus == null)
|
||||
{
|
||||
this.ToastError("Could not find Pending status for this company.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var newJob = new Job
|
||||
{
|
||||
JobNumber = await GenerateJobNumber(),
|
||||
CustomerId = source.CustomerId,
|
||||
CompanyId = companyId,
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = source.JobPriorityId,
|
||||
Description = source.Description,
|
||||
CustomerPO = source.CustomerPO,
|
||||
ProjectName = source.ProjectName,
|
||||
SpecialInstructions = source.SpecialInstructions,
|
||||
InternalNotes = source.InternalNotes,
|
||||
Tags = source.Tags,
|
||||
IsRushJob = source.IsRushJob,
|
||||
RequiresCustomerApproval = source.RequiresCustomerApproval,
|
||||
DiscountType = source.DiscountType,
|
||||
DiscountValue = source.DiscountValue,
|
||||
DiscountReason = source.DiscountReason,
|
||||
OvenCostId = source.OvenCostId,
|
||||
OvenBatches = source.OvenBatches,
|
||||
OvenCycleMinutes = source.OvenCycleMinutes,
|
||||
ShopSuppliesPercent = source.ShopSuppliesPercent,
|
||||
ShopAccessCode = Guid.NewGuid()
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(newJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted))
|
||||
{
|
||||
var newItem = new JobItem
|
||||
{
|
||||
JobId = newJob.Id,
|
||||
CompanyId = companyId,
|
||||
Description = srcItem.Description,
|
||||
Quantity = srcItem.Quantity,
|
||||
ColorName = srcItem.ColorName,
|
||||
ColorCode = srcItem.ColorCode,
|
||||
Finish = srcItem.Finish,
|
||||
SurfaceArea = srcItem.SurfaceArea,
|
||||
SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = srcItem.CatalogItemId,
|
||||
UnitPrice = srcItem.UnitPrice,
|
||||
TotalPrice = srcItem.TotalPrice,
|
||||
LaborCost = srcItem.LaborCost,
|
||||
IsGenericItem = srcItem.IsGenericItem,
|
||||
ManualUnitPrice = srcItem.ManualUnitPrice,
|
||||
PowderCostOverride = srcItem.PowderCostOverride,
|
||||
IsLaborItem = srcItem.IsLaborItem,
|
||||
IsSalesItem = srcItem.IsSalesItem,
|
||||
IsAiItem = srcItem.IsAiItem,
|
||||
AiTags = srcItem.AiTags,
|
||||
IsCustomFormulaItem = srcItem.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = srcItem.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson,
|
||||
Sku = srcItem.Sku,
|
||||
IncludePrepCost = srcItem.IncludePrepCost,
|
||||
RequiresSandblasting = srcItem.RequiresSandblasting,
|
||||
RequiresMasking = srcItem.RequiresMasking,
|
||||
EstimatedMinutes = srcItem.EstimatedMinutes,
|
||||
Complexity = srcItem.Complexity,
|
||||
Notes = srcItem.Notes
|
||||
// AiPredictionId intentionally not copied — prediction belongs to original quote
|
||||
};
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted))
|
||||
{
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
CompanyId = companyId,
|
||||
CoatName = srcCoat.CoatName,
|
||||
Sequence = srcCoat.Sequence,
|
||||
InventoryItemId = srcCoat.InventoryItemId,
|
||||
ColorName = srcCoat.ColorName,
|
||||
VendorId = srcCoat.VendorId,
|
||||
ColorCode = srcCoat.ColorCode,
|
||||
Finish = srcCoat.Finish,
|
||||
CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = srcCoat.TransferEfficiency,
|
||||
PowderCostPerLb = srcCoat.PowderCostPerLb,
|
||||
PowderToOrder = srcCoat.PowderToOrder,
|
||||
NoExtraLayerCharge = srcCoat.NoExtraLayerCharge,
|
||||
Notes = srcCoat.Notes
|
||||
// Powder ordering / receiving tracking fields intentionally not copied
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
CompanyId = companyId,
|
||||
PrepServiceId = srcPrep.PrepServiceId,
|
||||
EstimatedMinutes = srcPrep.EstimatedMinutes,
|
||||
BlastSetupId = srcPrep.BlastSetupId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
this.ToastSuccess($"Job cloned as {newJob.JobNumber} — review and update dates before scheduling.");
|
||||
return RedirectToAction(nameof(Details), new { id = newJob.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error cloning job {JobId}", id);
|
||||
this.ToastError("An error occurred while cloning the job.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter —
|
||||
/// this prevents number reuse if a job is deleted after being created this month.
|
||||
|
||||
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
|
||||
if (dto.SmsConsent)
|
||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||
|
||||
// Get "Converted" status (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
||||
|
||||
// Update quote to link to new customer
|
||||
// Update quote to link to new customer.
|
||||
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||
// user immediately click "Create Job from Quote" on the next screen.
|
||||
quote.CustomerId = customer.Id;
|
||||
|
||||
// Clear prospect fields
|
||||
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Update status to converted
|
||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
||||
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
||||
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
ProjectName = quote.ProjectName,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
IsRushJob = quote.IsRushJob,
|
||||
|
||||
@@ -328,6 +328,121 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Customer Notes -->
|
||||
@{
|
||||
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
|
||||
}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-sticky me-2 text-primary"></i>Internal Notes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="customer-notes-list">
|
||||
@if (customerNotes != null && customerNotes.Count > 0)
|
||||
{
|
||||
@foreach (var note in customerNotes)
|
||||
{
|
||||
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
|
||||
<div class="flex-grow-1">
|
||||
@if (note.IsImportant)
|
||||
{
|
||||
<span class="text-warning me-1" title="Important">★</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>
|
||||
|
||||
<!-- Right Column - Statistics -->
|
||||
@@ -378,6 +493,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outstanding Pickups -->
|
||||
@{
|
||||
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
|
||||
}
|
||||
@if (pendingPickups != null && pendingPickups.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
|
||||
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold text-warning-emphasis">
|
||||
<i class="bi bi-truck me-2"></i>Ready for Pickup
|
||||
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@foreach (var pickup in pendingPickups)
|
||||
{
|
||||
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
|
||||
<div class="flex-grow-1">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
|
||||
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
|
||||
@if (!string.IsNullOrEmpty(pickup.Description))
|
||||
{
|
||||
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
|
||||
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Store Credit History -->
|
||||
@{
|
||||
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
||||
@@ -430,33 +580,146 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Activity -->
|
||||
<!-- Customer Stats -->
|
||||
@{
|
||||
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
|
||||
}
|
||||
@if (crmStats != null)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-clock-history me-2 text-primary"></i>Activity
|
||||
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Last Contact</label>
|
||||
<p class="mb-0">
|
||||
@if (Model.LastContactDate.HasValue)
|
||||
<!-- Jobs row -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
|
||||
<div class="text-muted small mb-1">Total Jobs</div>
|
||||
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
|
||||
@if (crmStats.ActiveJobs > 0)
|
||||
{
|
||||
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
|
||||
<span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
|
||||
@crmStats.ActiveJobs active
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="col-6 text-center p-2">
|
||||
<div class="text-muted small mb-1">Avg Job Value</div>
|
||||
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<!-- Revenue row -->
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<div class="text-muted small mb-1">Lifetime Revenue</div>
|
||||
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small mb-1">Total Collected</div>
|
||||
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<!-- Footer stats -->
|
||||
<div class="d-flex justify-content-between text-muted small mt-2">
|
||||
<span>
|
||||
@if (crmStats.DaysSinceLastJob.HasValue)
|
||||
{
|
||||
<i class="bi bi-calendar-check me-1"></i>
|
||||
@if (crmStats.DaysSinceLastJob == 0)
|
||||
{
|
||||
<span>Last job today</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No contact recorded</span>
|
||||
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No jobs yet</span>
|
||||
}
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Customer Since</label>
|
||||
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
||||
<div class="text-muted small mt-1">
|
||||
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Preferred Powders -->
|
||||
@{
|
||||
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
|
||||
}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="preferred-powders-list">
|
||||
@if (preferredPowders != null && preferredPowders.Count > 0)
|
||||
{
|
||||
@foreach (var p in preferredPowders)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
|
||||
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<span class="small fw-semibold">@p.InventoryItem.Name</span>
|
||||
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
|
||||
{
|
||||
<span class="text-muted small ms-1">— @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 -->
|
||||
<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's items and settings">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
|
||||
</a>
|
||||
}
|
||||
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
|
||||
<i class="bi bi-file-text me-2"></i>New Quote
|
||||
</a>
|
||||
|
||||
@@ -44,11 +44,21 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
|
||||
title="Click to filter list to low stock items">
|
||||
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
|
||||
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
|
||||
style="cursor:pointer;transition:box-shadow .15s;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
|
||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">
|
||||
Low Stock Items
|
||||
@if (lowStockCount > 0)
|
||||
{
|
||||
<i class="bi bi-funnel-fill ms-1 text-danger" style="font-size:.7rem;" title="Click to filter"></i>
|
||||
}
|
||||
</p>
|
||||
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
|
||||
</div>
|
||||
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
||||
@@ -57,6 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
@@ -102,11 +113,13 @@
|
||||
<div class="stat-value">@Model.TotalCount</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
|
||||
<div class="stat-item" style="cursor:pointer;">
|
||||
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
|
||||
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
||||
<div class="stat-label">Low Stock</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
||||
<div class="stat-value">@activeCount</div>
|
||||
|
||||
@@ -170,6 +170,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
|
||||
@@ -193,6 +193,13 @@
|
||||
<p class="mb-0">@Model.CustomerPO</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Project Name</label>
|
||||
<p class="mb-0">@Model.ProjectName</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
||||
|
||||
@@ -124,6 +124,10 @@
|
||||
</div>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
title="Save this job as a reusable template">
|
||||
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
|
||||
</button>
|
||||
<a asp-action="CloneJob" asp-route-id="@Model.Id" class="btn btn-outline-secondary"
|
||||
title="Create a new job pre-filled with this job's items and settings">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Clone Job
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
||||
<i class="bi bi-pencil me-2"></i>Edit
|
||||
</a>
|
||||
@@ -172,6 +176,13 @@
|
||||
<label class="text-muted small mb-1">Customer PO</label>
|
||||
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Project</label>
|
||||
<p class="mb-0">@Model.ProjectName</p>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="text-muted small mb-1">Description</label>
|
||||
<p class="mb-0">@Model.Description</p>
|
||||
|
||||
@@ -101,6 +101,10 @@
|
||||
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
|
||||
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
|
||||
|
||||
@@ -357,6 +357,13 @@
|
||||
<div class="info-value">@Model.CustomerPO</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||
{
|
||||
<div class="info-row">
|
||||
<div class="info-label">Project</div>
|
||||
<div class="info-value">@Model.ProjectName</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="section-title">
|
||||
|
||||
@@ -187,6 +187,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label"></label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -183,6 +183,10 @@
|
||||
{
|
||||
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||
{
|
||||
<p><strong>Project:</strong> @Model.ProjectName</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
|
||||
@@ -150,6 +150,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label"></label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -38,6 +38,148 @@ async function cancelSmsConsent() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Notes ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addCustomerNote(customerId) {
|
||||
const textarea = document.getElementById('newNoteText');
|
||||
const importantCb = document.getElementById('newNoteImportant');
|
||||
const note = textarea?.value?.trim();
|
||||
if (!note) { toastr.warning('Please enter a note.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
const placeholder = document.getElementById('no-notes-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
list.insertAdjacentHTML('afterbegin', data.noteHtml);
|
||||
textarea.value = '';
|
||||
if (importantCb) importantCb.checked = false;
|
||||
toastr.success('Note added.');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not add note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomerNote(customerId, noteId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `noteId=${noteId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
if (list && list.querySelectorAll('.customer-note-item').length === 0)
|
||||
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not delete note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preferred Powders ─────────────────────────────────────────────────────────
|
||||
|
||||
let _powderSearchTimer = null;
|
||||
|
||||
function searchInventoryItems(term) {
|
||||
clearTimeout(_powderSearchTimer);
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (!term || term.length < 2) { if (dropdown) dropdown.innerHTML = ''; return; }
|
||||
|
||||
_powderSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
|
||||
const data = await res.json();
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = data.length === 0
|
||||
? '<div class="dropdown-item text-muted small">No results</div>'
|
||||
: data.map(i => `<button type="button" class="dropdown-item small"
|
||||
onclick="selectPowder(${i.id}, ${JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : ''))})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku}</span></button>`).join('');
|
||||
} catch { /* silent */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectPowder(itemId, label) {
|
||||
document.getElementById('selectedPowderId').value = itemId;
|
||||
document.getElementById('powderSearchInput').value = label;
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (dropdown) dropdown.innerHTML = '';
|
||||
}
|
||||
|
||||
async function addPreferredPowder(customerId) {
|
||||
const itemId = document.getElementById('selectedPowderId')?.value;
|
||||
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
|
||||
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `inventoryItemId=${itemId}¬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 () {
|
||||
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!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user