Add CRM features: Additional Contacts, Lead Source, Ship-To Address; update Help docs

- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:46:08 -04:00
parent 711cd01cd3
commit 94a89ee175
22 changed files with 12586 additions and 31 deletions
@@ -0,0 +1,64 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Application.DTOs.Customer;
public class CustomerContactDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string? LastName { get; set; }
public string? Title { get; set; }
public string? ContactRole { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Notes { get; set; }
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
}
public class CreateCustomerContactDto
{
[Required(ErrorMessage = "First name is required.")]
[StringLength(100)]
[Display(Name = "First Name")]
public string FirstName { get; set; } = string.Empty;
[StringLength(100)]
[Display(Name = "Last Name")]
public string? LastName { get; set; }
[StringLength(100)]
[Display(Name = "Job Title")]
public string? Title { get; set; }
[StringLength(50)]
[Display(Name = "Role")]
public string? ContactRole { get; set; }
[EmailAddress]
[StringLength(200)]
[Display(Name = "Email")]
public string? Email { get; set; }
[Phone]
[StringLength(20)]
[Display(Name = "Phone")]
public string? Phone { get; set; }
[Phone]
[StringLength(20)]
[Display(Name = "Mobile Phone")]
public string? MobilePhone { get; set; }
[StringLength(500)]
[Display(Name = "Notes")]
public string? Notes { get; set; }
}
public class UpdateCustomerContactDto : CreateCustomerContactDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
}
@@ -36,6 +36,16 @@ public class CustomerDto
public bool NotifyBySms { get; set; }
public DateTime? SmsConsentedAt { get; set; }
public string? SmsConsentMethod { get; set; }
// CRM
public string? LeadSource { get; set; }
// Ship-to address
public string? ShipToAddress { get; set; }
public string? ShipToCity { get; set; }
public string? ShipToState { get; set; }
public string? ShipToZipCode { get; set; }
public string? ShipToCountry { get; set; }
}
public class CreateCustomerDto : IValidatableObject
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
[StringLength(2000)]
public string? GeneralNotes { get; set; }
[Display(Name = "How did you find us?")]
[StringLength(100)]
public string? LeadSource { get; set; }
// Ship-to / alternate address
[Display(Name = "Ship-To Street Address")]
[StringLength(500)]
public string? ShipToAddress { get; set; }
[Display(Name = "City")]
[StringLength(100)]
public string? ShipToCity { get; set; }
[Display(Name = "State")]
[StringLength(50)]
public string? ShipToState { get; set; }
[Display(Name = "Zip Code")]
[StringLength(20)]
public string? ShipToZipCode { get; set; }
[Display(Name = "Country")]
[StringLength(100)]
public string? ShipToCountry { get; set; }
[Display(Name = "Notify by Email")]
public bool NotifyByEmail { get; set; } = true;
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
: string.Empty));
// CustomerContact
CreateMap<CustomerContact, CustomerContactDto>();
CreateMap<CreateCustomerContactDto, CustomerContact>();
CreateMap<UpdateCustomerContactDto, CustomerContact>()
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
CreateMap<CustomerContact, UpdateCustomerContactDto>();
}
}
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
public bool IsActive { get; set; } = true;
public DateTime? LastContactDate { get; set; }
// CRM fields
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
public string? LeadSource { get; set; }
// Ship-to / alternate address (separate from billing address above)
public string? ShipToAddress { get; set; }
public string? ShipToCity { get; set; }
public string? ShipToState { get; set; }
public string? ShipToZipCode { get; set; }
public string? ShipToCountry { get; set; }
// Notification preferences
public bool NotifyByEmail { get; set; } = true;
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
}
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Core.Entities;
/// <summary>
/// An additional contact person associated with a customer account.
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
/// The primary contact remains on the Customer entity; these are supplementary.
/// </summary>
public class CustomerContact : BaseEntity
{
public int CustomerId { get; set; }
[Required]
[StringLength(100)]
public string FirstName { get; set; } = string.Empty;
[StringLength(100)]
public string? LastName { get; set; }
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
[StringLength(100)]
public string? Title { get; set; }
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
[StringLength(50)]
public string? ContactRole { get; set; }
[StringLength(200)]
public string? Email { get; set; }
[StringLength(20)]
public string? Phone { get; set; }
[StringLength(20)]
public string? MobilePhone { get; set; }
[StringLength(500)]
public string? Notes { get; set; }
public virtual Customer? Customer { get; set; }
}
@@ -43,6 +43,7 @@ public interface IUnitOfWork : IDisposable
IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; }
IRepository<CustomerContact> CustomerContacts { 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>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
public DbSet<CustomerContact> CustomerContacts { 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>
@@ -0,0 +1,164 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomerContactsAndCrmFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LeadSource",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShipToAddress",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShipToCity",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShipToCountry",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShipToState",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShipToZipCode",
table: "Customers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "CustomerContacts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<int>(type: "int", nullable: false),
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
ContactRole = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
MobilePhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, 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_CustomerContacts", x => x.Id);
table.ForeignKey(
name: "FK_CustomerContacts_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
migrationBuilder.CreateIndex(
name: "IX_CustomerContacts_CustomerId",
table: "CustomerContacts",
column: "CustomerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerContacts");
migrationBuilder.DropColumn(
name: "LeadSource",
table: "Customers");
migrationBuilder.DropColumn(
name: "ShipToAddress",
table: "Customers");
migrationBuilder.DropColumn(
name: "ShipToCity",
table: "Customers");
migrationBuilder.DropColumn(
name: "ShipToCountry",
table: "Customers");
migrationBuilder.DropColumn(
name: "ShipToState",
table: "Customers");
migrationBuilder.DropColumn(
name: "ShipToZipCode",
table: "Customers");
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));
}
}
}
@@ -2818,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastContactDate")
.HasColumnType("datetime2");
b.Property<string>("LeadSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("MobilePhone")
.HasColumnType("nvarchar(max)");
@@ -2836,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("PricingTierId")
.HasColumnType("int");
b.Property<string>("ShipToAddress")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToCity")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToCountry")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToState")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToZipCode")
.HasColumnType("nvarchar(max)");
b.Property<string>("SmsConsentMethod")
.HasColumnType("nvarchar(max)");
@@ -2894,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Customers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<string>("ContactRole")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
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<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("MobilePhone")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Title")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("CustomerContacts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{
b.Property<int>("Id")
@@ -7111,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947),
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7122,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953),
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7133,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954),
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -9535,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("PricingTier");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
.WithMany("CustomerContacts")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
@@ -11060,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{
b.Navigation("CustomerContacts");
b.Navigation("CustomerNotes");
b.Navigation("Invoices");
@@ -70,6 +70,7 @@ public class UnitOfWork : IUnitOfWork
private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes;
private IRepository<CustomerContact>? _customerContacts;
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
private IRepository<JobStatusHistory>? _jobStatusHistory;
private IRepository<PricingTier>? _pricingTiers;
@@ -322,6 +323,9 @@ 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);
/// <summary>Repository for <see cref="CustomerContact"/> additional contacts (billing, ops, drop-off) on commercial accounts; tenant-filtered with soft delete.</summary>
public IRepository<CustomerContact> CustomerContacts =>
_customerContacts ??= new Repository<CustomerContact>(_context);
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
@@ -197,6 +197,11 @@ public class CustomersController : Controller
.ToList();
ViewBag.PreferredPowders = preferredPowders;
var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value))
.OrderBy(c => c.FirstName)
.ToList();
ViewBag.CustomerContacts = customerContacts;
// Stats
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
var stats = new CustomerLifetimeStatsDto
@@ -1209,6 +1214,148 @@ public class CustomersController : Controller
}
}
// ── Customer Contacts ──────────────────────────────────────────────────
/// <summary>
/// Returns the JSON representation of a single contact for pre-populating the edit modal.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetContact(int id, int contactId)
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false });
var dto = _mapper.Map<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
return Json(new { success = true, contact = dto });
}
/// <summary>
/// Adds a new contact to the customer record. Returns rendered row HTML so the
/// caller can append it to the contacts table without a full reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var entity = _mapper.Map<PowderCoating.Core.Entities.CustomerContact>(dto);
entity.CustomerId = id;
entity.CompanyId = companyId;
await _unitOfWork.CustomerContacts.AddAsync(entity);
await _unitOfWork.CompleteAsync();
var rowHtml = BuildContactRowHtml(id, entity);
return Json(new { success = true, contactId = entity.Id, rowHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding contact to customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Updates an existing contact record in place. Returns the updated row HTML.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
try
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false, message = "Contact not found." });
_mapper.Map(dto, contact);
contact.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CustomerContacts.UpdateAsync(contact);
await _unitOfWork.CompleteAsync();
var rowHtml = BuildContactRowHtml(id, contact);
return Json(new { success = true, contactId = contact.Id, rowHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Soft-deletes a contact. Only the owning company can delete their contacts
/// (enforced via CompanyId + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteContact(int id, int contactId)
{
try
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false, message = "Contact not found." });
await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Builds the table-row HTML for a contact. Kept server-side so the same markup is
/// used for both the initial page render and the AJAX insert/replace path.
/// </summary>
private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c)
{
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
var titlePart = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : "";
var roleBadge = !string.IsNullOrWhiteSpace(c.ContactRole)
? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
: "";
var email = !string.IsNullOrWhiteSpace(c.Email)
? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
: "<span class=\"text-muted small\">&mdash;</span>";
var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
: "<span class=\"text-muted small\">&mdash;</span>";
return $@"<tr data-contact-id=""{c.Id}"">
<td>
<div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
</td>
<td>{email}</td>
<td>{phone}</td>
<td class=""text-end"">
<button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
<i class=""bi bi-pencil""></i>
</button>
<button type=""button"" class=""btn btn-sm btn-outline-danger""
onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
<i class=""bi bi-trash""></i>
</button>
</td>
</tr>";
}
/// <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.
@@ -153,7 +153,7 @@ public static class HelpKnowledgeBase
- *Commercial*: Businesses. Can have a pricing tier, credit limit, tax exempt status, and linked quotes/jobs.
- *Non-Commercial*: Individual consumers. Simpler setup.
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes.
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes, lead source, ship-to address.
**How to add a customer:**
1. Go to [Customers](/Customers)
@@ -161,10 +161,20 @@ public static class HelpKnowledgeBase
3. Fill in name, contact info, select type
4. Save
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes.
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes, additional contacts.
**Customer Notes:** Add internal notes on the Details page. Notes are private (not visible to the customer).
**Additional Contacts:** Store billing contacts, ops contacts, drop-off contacts, etc. on the Customer Details page. These are for staff reference only all automated notifications (emails, SMS) go to the primary email/phone on the main customer record, not to additional contacts. If invoices need to go to a separate address, use the Billing Email field on the main record.
**Lead Source:** Optional field on the customer record indicating how they found the shop (Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade Show, Flyer/Print, Other).
**Ship-To Address:** Optional separate address for pickups or deliveries. Shown alongside the billing address on the Customer Details page when set.
**Preferred Powders:** On the Customer Details page, the Preferred Powders card lets staff tag inventory items that a customer regularly orders. Use the search box to find a powder by name or SKU and click Add. Remove with the × button. This is a staff-reference tool only it does not auto-select powders on quotes or jobs. Items must already exist in Inventory to appear in the search.
**Outstanding Pickups (Ready for Pickup card):** When one or more of a customer's jobs are in "Ready for Pickup" status, a highlighted card appears on their Customer Details page showing each job number and how many days it has been waiting. Color coding: amber = 36 days, red = 7+ days. The card disappears once all jobs move out of Ready for Pickup status. Useful for front desk staff to instantly see during a call whether parts are ready for this customer.
**Deactivating a customer:** Use the Delete/Deactivate option this soft-deletes (hides) the customer but does not erase data.
**Pricing Tiers:** Assign a tier (configured at [Pricing Tiers](/PricingTiers)) to automatically apply a discount to that customer's quotes.
@@ -194,8 +204,9 @@ public static class HelpKnowledgeBase
2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top
3. Select existing customer OR enter prospect info (name, email, phone)
4. Add line items using the item wizard (3 item types below)
5. Review the pricing breakdown
6. Save as Draft or Send immediately
5. Optionally enter a **Project Name** a short label (e.g. "Shop Equipment Rack") that carries through to the job and invoice when the quote is converted.
6. Review the pricing breakdown
7. Save as Draft or Send immediately
**Item types in the quote/job wizard:**
1. *Product from Catalog* pick a pre-priced catalog item; price is fixed, no surface-area calculation
@@ -286,8 +297,9 @@ public static class HelpKnowledgeBase
2. Select customer
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
4. Set priority, due date, assigned worker, special instructions
5. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
6. Save
5. Optionally enter a **Project Name** a short label (e.g. "Front Gate Panels") that appears on the job, linked invoice, and printed documents to help the customer identify what the work is for.
6. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
7. Save
**Job Priority Board:** [/JobsPriority](/JobsPriority) Kanban-style view of all active jobs sorted by priority and status.
@@ -328,6 +340,8 @@ public static class HelpKnowledgeBase
**Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
**Cloning a Job:** On any Job Details page, click the **Clone Job** button (copy icon in the header toolbar). The system creates a new draft job immediately and redirects you to it. The clone copies: customer, description, PO number, project name, special instructions, tags, priority, discount %, oven settings, and all line items with their coats and prep services. It does NOT copy: due date, scheduled date, assigned worker, photos, notes, time entries, status history, or any linked invoice or payments. The clone starts in Pending status so it goes through the normal workflow.
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
@@ -377,8 +391,10 @@ public static class HelpKnowledgeBase
- *Voided* cancelled invoice
- *Written Off* uncollectable, written off
**Project Name on invoices:** If the linked job had a Project Name set, it auto-fills on the invoice and appears on the printed PDF to help the customer identify the work.
**How to create an invoice:**
1. From the Job Details page "Create Invoice" (recommended pre-fills all items), OR
1. From the Job Details page "Create Invoice" (recommended pre-fills all items including Project Name), OR
2. Go to [Invoices](/Invoices) "New Invoice" and select a job
**Recording a payment:**
@@ -470,6 +486,8 @@ public static class HelpKnowledgeBase
- **Low Stock** (red) quantity is greater than zero but at or below the reorder point; time to reorder
- **Out of Stock** (dark/black) quantity is zero; an alert banner appears on the Details page
**Low Stock stat card (clickable filter):** The "Low Stock" KPI card at the top of the Inventory page is clickable. Click it to instantly filter the list to only items needing reorder. Click it again (or clear the filter banner) to return to the full list. This is the fastest way to generate a reorder checklist.
**Stock Adjustment:** From Inventory Details, click "Stock Adjustment" to open the quick-adjust modal. Choose Add Stock, Remove Stock, or Set Exact, enter the quantity, select a reason (required), and optionally add notes. Every adjustment is automatically recorded as a transaction with the reason and notes included.
**Inventory transactions:** Every stock movement is recorded automatically Initial (item creation), Purchase (PO receipt), Adjustment (manual or edit), Job Usage (powder consumed on a job coat), Sale, Return, Waste, Transfer. Each record stores date, quantity delta, unit cost, and running balance after the change.
@@ -209,6 +209,42 @@
</div>
</div>
<!-- Ship-To Address Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Ship-To Address"
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
<i class="bi bi-question-circle"></i>
</a>
<span class="text-muted small fw-normal">(optional &mdash; leave blank if same as billing)</span>
</div>
<div class="row g-3">
<div class="col-12">
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
</div>
<div class="col-md-5">
<label asp-for="ShipToCity" class="form-label">City</label>
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
</div>
<div class="col-md-3">
<label asp-for="ShipToState" class="form-label">State</label>
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
</div>
<div class="col-md-2">
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
</div>
<div class="col-md-2">
<label asp-for="ShipToCountry" class="form-label">Country</label>
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
</div>
</div>
</div>
<!-- Business Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
@@ -282,6 +318,30 @@
</div>
</div>
<!-- Lead Source Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="LeadSource" class="form-label">Lead Source</label>
<select asp-for="LeadSource" class="form-select">
<option value="">&mdash; Not specified &mdash;</option>
<option value="Walk-In">Walk-In</option>
<option value="Google Search">Google Search</option>
<option value="Customer Referral">Customer Referral</option>
<option value="Social Media">Social Media</option>
<option value="Website">Website</option>
<option value="Repeat Customer">Repeat Customer</option>
<option value="Trade Show / Event">Trade Show / Event</option>
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
@@ -216,27 +216,50 @@
</h5>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(Model.Address))
@{
bool hasBilling = !string.IsNullOrEmpty(Model.Address);
bool hasShipTo = !string.IsNullOrEmpty(Model.ShipToAddress) || !string.IsNullOrEmpty(Model.ShipToCity);
}
@if (hasShipTo)
{
<div class="row g-3">
<div class="col-md-6">
<label class="text-muted small mb-1">Billing Address</label>
@if (hasBilling)
{
<p class="mb-1">@Model.Address</p>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
</p>
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
}
else { <p class="text-muted mb-0">Not provided</p> }
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">
<i class="bi bi-truck me-1"></i>Ship-To / Pickup Address
</label>
<p class="mb-1">@Model.ShipToAddress</p>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.ShipToCity)) { <span>@Model.ShipToCity</span> }
@if (!string.IsNullOrEmpty(Model.ShipToState)) { <span>, @Model.ShipToState</span> }
@if (!string.IsNullOrEmpty(Model.ShipToZipCode)) { <span> @Model.ShipToZipCode</span> }
</p>
@if (!string.IsNullOrEmpty(Model.ShipToCountry)) { <p class="mb-0 text-muted">@Model.ShipToCountry</p> }
</div>
</div>
}
else if (hasBilling)
{
<p class="mb-2">@Model.Address</p>
<p class="mb-0">
@if (!string.IsNullOrEmpty(Model.City))
{
<span>@Model.City</span>
}
@if (!string.IsNullOrEmpty(Model.State))
{
<span>, @Model.State</span>
}
@if (!string.IsNullOrEmpty(Model.ZipCode))
{
<span> @Model.ZipCode</span>
}
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
</p>
@if (!string.IsNullOrEmpty(Model.Country))
{
<p class="mb-0 text-muted">@Model.Country</p>
}
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
}
else
{
@@ -262,6 +285,15 @@
<label class="text-muted small mb-1">Payment Terms</label>
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
</div>
@if (!string.IsNullOrEmpty(Model.LeadSource))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Lead Source</label>
<p class="mb-0">
<i class="bi bi-signpost me-1 text-muted"></i>@Model.LeadSource
</p>
</div>
}
<div class="col-md-6">
<label class="text-muted small mb-1">Credit Limit</label>
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
@@ -329,6 +361,96 @@
</div>
}
<!-- Additional Contacts -->
@{
var customerContacts = ViewBag.CustomerContacts as List<PowderCoating.Core.Entities.CustomerContact>;
}
<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-people me-2 text-primary"></i>Additional Contacts
</h5>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">
<i class="bi bi-info-circle me-1"></i>For staff reference &mdash; automated notifications still go to the primary contact above.
</span>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#contactModal"
onclick="openAddContactModal()">
<i class="bi bi-plus-circle me-1"></i>Add Contact
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Name / Role</th>
<th>Email</th>
<th>Phone</th>
<th class="text-end pe-3"></th>
</tr>
</thead>
<tbody id="contacts-table-body">
@if (customerContacts != null && customerContacts.Count > 0)
{
@foreach (var c in customerContacts)
{
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
<tr data-contact-id="@c.Id">
<td class="ps-3">
<div class="fw-semibold">
@displayName
@if (!string.IsNullOrEmpty(c.ContactRole))
{
<span class="badge bg-secondary bg-opacity-10 text-secondary ms-1">@c.ContactRole</span>
}
</div>
@if (!string.IsNullOrEmpty(c.Title))
{
<div class="text-muted" style="font-size:0.75rem;">@c.Title</div>
}
</td>
<td>
@if (!string.IsNullOrEmpty(c.Email))
{
<a href="mailto:@c.Email" class="text-decoration-none small">@c.Email</a>
}
else { <span class="text-muted small">&mdash;</span> }
</td>
<td>
@if (!string.IsNullOrEmpty(c.Phone ?? c.MobilePhone))
{
<span class="small">@(c.Phone ?? c.MobilePhone)</span>
}
else { <span class="text-muted small">&mdash;</span> }
</td>
<td class="text-end pe-3">
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
onclick="editContact(@Model.Id, @c.Id)" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteContact(@Model.Id, @c.Id)" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
}
else
{
<tr id="no-contacts-placeholder">
<td colspan="4" class="text-muted small px-3 py-2">No additional contacts. Click &ldquo;Add Contact&rdquo; to add billing, ops, or drop-off contacts.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Customer Notes -->
@{
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
@@ -776,6 +898,72 @@
</div>
</div>
<!-- Add / Edit Contact Modal -->
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contactModalLabel">
<i class="bi bi-person-plus me-2 text-primary"></i><span id="contactModalTitle">Add Contact</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="contactId" value="0" />
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">First Name <span class="text-danger">*</span></label>
<input type="text" id="contactFirstName" class="form-control" maxlength="100" placeholder="First name" />
</div>
<div class="col-md-6">
<label class="form-label">Last Name</label>
<input type="text" id="contactLastName" class="form-control" maxlength="100" placeholder="Last name" />
</div>
<div class="col-md-6">
<label class="form-label">Job Title</label>
<input type="text" id="contactTitle" class="form-control" maxlength="100" placeholder="e.g. Purchasing Manager" />
</div>
<div class="col-md-6">
<label class="form-label">Role</label>
<select id="contactRole" class="form-select">
<option value="">&mdash; Select &mdash;</option>
<option value="Billing">Billing</option>
<option value="Operations">Operations</option>
<option value="Drop-Off">Drop-Off</option>
<option value="Sales">Sales</option>
<option value="General">General</option>
<option value="Other">Other</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Email</label>
<input type="email" id="contactEmail" class="form-control" maxlength="200" placeholder="email@example.com" />
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="tel" id="contactPhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
</div>
<div class="col-md-6">
<label class="form-label">Mobile Phone</label>
<input type="tel" id="contactMobilePhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea id="contactNotes" class="form-control" rows="2" maxlength="500" placeholder="Optional notes about this contact..."></textarea>
</div>
</div>
<div id="contactModalError" class="alert alert-danger alert-permanent mt-3 d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveContact(@Model.Id)" id="saveContactBtn">
<i class="bi bi-check-circle me-1"></i>Save Contact
</button>
</div>
</div>
</div>
</div>
<!-- Add Store Credit Modal -->
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
{
@@ -213,6 +213,42 @@
</div>
</div>
<!-- Ship-To Address Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Ship-To Address"
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
<i class="bi bi-question-circle"></i>
</a>
<span class="text-muted small fw-normal">(optional &mdash; leave blank if same as billing)</span>
</div>
<div class="row g-3">
<div class="col-12">
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
</div>
<div class="col-md-5">
<label asp-for="ShipToCity" class="form-label">City</label>
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
</div>
<div class="col-md-3">
<label asp-for="ShipToState" class="form-label">State</label>
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
</div>
<div class="col-md-2">
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
</div>
<div class="col-md-2">
<label asp-for="ShipToCountry" class="form-label">Country</label>
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
</div>
</div>
</div>
<!-- Business Information Section -->
<div class="mb-4">
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
@@ -270,6 +306,30 @@
</div>
</div>
<!-- Lead Source Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
</h5>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="LeadSource" class="form-label">Lead Source</label>
<select asp-for="LeadSource" class="form-select">
<option value="">&mdash; Not specified &mdash;</option>
<option value="Walk-In">Walk-In</option>
<option value="Google Search">Google Search</option>
<option value="Customer Referral">Customer Referral</option>
<option value="Social Media">Social Media</option>
<option value="Website">Website</option>
<option value="Repeat Customer">Repeat Customer</option>
<option value="Trade Show / Event">Trade Show / Event</option>
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<!-- Notes Section -->
<div class="mb-4">
<h5 class="border-bottom pb-2 mb-3">
@@ -136,8 +136,26 @@
</p>
<p>The details page shows:</p>
<ul>
<li><strong>Contact information</strong> &mdash; name, email, phone, and address.</li>
<li><strong>Account summary</strong> &mdash; current balance, credit limit, and pricing tier.</li>
<li><strong>Contact information</strong> &mdash; name, email, phone, address, and lead source.</li>
<li><strong>Account summary</strong> &mdash; current balance, credit limit, store credit, and pricing tier.</li>
<li>
<strong>Ready for Pickup</strong> &mdash; if any of this customer&rsquo;s jobs are in &ldquo;Ready for Pickup&rdquo; status,
a highlighted card appears in the right column showing each job number and how many days it has been waiting.
Jobs waiting 3&ndash;6 days show in amber; 7+ days in red.
</li>
<li>
<strong>Additional Contacts</strong> &mdash; billing contacts, ops contacts, drop-off contacts, and so on.
See the Additional Contacts section below.
</li>
<li>
<strong>Internal Notes</strong> &mdash; private notes added by your staff (not visible to the customer).
Notes can be marked as important <span class="text-warning">&#9733;</span> to highlight them for the team.
</li>
<li>
<strong>Preferred Powders</strong> &mdash; inventory items this customer frequently uses. Staff can
search and add powders here so that anyone creating a quote or job for this customer can quickly
see which colors they prefer. See the Preferred Powders section below.
</li>
<li>
<strong>Jobs tab</strong> &mdash; every job created for this customer, with status and date. Click
a job number to open it.
@@ -153,7 +171,7 @@
<li>
<strong>Deposits tab</strong> &mdash; all deposits recorded for this customer across any job or quote.
</li>
<li><strong>Notes</strong> &mdash; any notes saved against the customer record.</li>
<li><strong>Recent Activity</strong> &mdash; a combined timeline of the last 15 events (jobs, quotes, invoices, deposits) in reverse chronological order.</li>
</ul>
</section>
@@ -195,6 +213,122 @@
</p>
</section>
<section id="additional-contacts" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-people text-primary me-2"></i>Additional Contacts
</h2>
<p>
Commercial customers often have more than one person involved in their account &mdash; a purchasing
manager, a billing contact, or the person who actually drops off and picks up parts. The
<strong>Additional Contacts</strong> section on the Customer Details page lets you store all of
them in one place so your team always knows who to call.
</p>
<p>To add a contact, open the Customer Details page and click <strong>Add Contact</strong> in the
Additional Contacts card. You can record:</p>
<ul class="mb-3">
<li><strong>Name</strong> &mdash; first and last name.</li>
<li><strong>Job Title</strong> &mdash; their role at the company (e.g., &ldquo;Purchasing Manager&rdquo;).</li>
<li><strong>Role</strong> &mdash; a category tag: Billing, Operations, Drop-Off, Sales, General, or Other.</li>
<li><strong>Email &amp; Phone</strong> &mdash; their direct contact details.</li>
<li><strong>Notes</strong> &mdash; anything else your team should know about this person.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Notifications always go to the primary contact.</strong> Additional contacts are for
staff reference only. All automated emails (job ready for pickup, invoice sent, quote
approval links, etc.) and SMS messages are sent to the email address and phone number on the
main customer record &mdash; not to the contacts listed here. If you need invoices routed to a
different address, use the <strong>Billing / Accounting Email</strong> field on the main
customer record instead.
</div>
</div>
</section>
<section id="ship-to-address" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-truck text-primary me-2"></i>Ship-To / Pickup Address
</h2>
<p>
Some customers have a different address for pickups or deliveries than their billing address. You
can record a separate <strong>Ship-To</strong> address on the Create or Edit form. Leave it blank
if the customer picks up from the same address they bill from.
</p>
<p>
When a ship-to address is on file, the Customer Details page splits the Address card into two
columns &mdash; billing on the left, ship-to on the right &mdash; so the difference is immediately visible
to anyone looking up the customer.
</p>
</section>
<section id="lead-source" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-signpost text-primary me-2"></i>Lead Source
</h2>
<p>
The <strong>Lead Source</strong> field lets you record how a customer found your shop. Options
include Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade
Show / Event, Flyer / Print Ad, and Other.
</p>
<p>
This field is optional and is shown on the Customer Details page under Business Information. It
is useful for understanding which marketing channels are bringing in customers over time.
</p>
</section>
<section id="preferred-powders" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-droplet-half text-primary me-2"></i>Preferred Powders
</h2>
<p>
The <strong>Preferred Powders</strong> card on the Customer Details page lets you tag inventory
items that this customer regularly orders. It is a staff-reference tool &mdash; it does not auto-select
powders on quotes or jobs, but it gives anyone creating a quote a quick look at what colors this
customer has used before.
</p>
<p>To add a preferred powder:</p>
<ol class="mb-3">
<li class="mb-1">Open the Customer Details page.</li>
<li class="mb-1">In the <strong>Preferred Powders</strong> card, type part of the powder name or SKU into the search box.</li>
<li class="mb-1">Select the item from the dropdown and click <strong>Add</strong>.</li>
</ol>
<p>To remove a preferred powder, click the <strong>&times;</strong> button next to the item in the list.</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
<div>
Only items that already exist in your <strong>Inventory</strong> can be added as preferred powders.
If a color isn&rsquo;t appearing in the search, check that it has been added to inventory first.
</div>
</div>
</section>
<section id="outstanding-pickups" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-seam text-primary me-2"></i>Outstanding Pickups
</h2>
<p>
When one or more of a customer&rsquo;s jobs are in <strong>Ready for Pickup</strong> status, a
highlighted card appears in the right column of their Customer Details page. This lets your front desk
staff immediately see &mdash; without opening the Jobs list &mdash; whether a customer calling or walking
in has finished work waiting for them.
</p>
<p>The card shows:</p>
<ul class="mb-3">
<li>The job number (clickable, opens the Job Details page).</li>
<li>How many days the job has been waiting in &ldquo;Ready for Pickup&rdquo; status.</li>
</ul>
<p>Color coding helps prioritize follow-up calls:</p>
<ul class="mb-3">
<li><span class="badge bg-warning text-dark">Amber</span> &mdash; waiting 3&ndash;6 days.</li>
<li><span class="badge bg-danger">Red</span> &mdash; waiting 7 or more days.</li>
<li>No color &mdash; waiting 0&ndash;2 days (recently completed).</li>
</ul>
<p>
The card disappears automatically once all jobs for this customer have moved out of
&ldquo;Ready for Pickup&rdquo; status (e.g., to Delivered).
</p>
</section>
<section id="deactivating-a-customer" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Customer
@@ -235,6 +369,11 @@
<a class="nav-link py-1 px-3 small text-body" href="#customer-details">Customer Details Page</a>
<a class="nav-link py-1 px-3 small text-body" href="#credit-limit">Credit Limit</a>
<a class="nav-link py-1 px-3 small text-body" href="#tax-exempt">Tax Exempt</a>
<a class="nav-link py-1 px-3 small text-body" href="#additional-contacts">Additional Contacts</a>
<a class="nav-link py-1 px-3 small text-body" href="#ship-to-address">Ship-To Address</a>
<a class="nav-link py-1 px-3 small text-body" href="#lead-source">Lead Source</a>
<a class="nav-link py-1 px-3 small text-body" href="#preferred-powders">Preferred Powders</a>
<a class="nav-link py-1 px-3 small text-body" href="#outstanding-pickups">Outstanding Pickups</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-customer">Deactivating a Customer</a>
</nav>
</div>
@@ -415,7 +415,11 @@
An alert banner is shown on the item's Details page prompting you to use Stock Adjustment to add inventory.
</li>
</ul>
<p>Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report. Use the <strong>Low Stock</strong> filter on the Inventory list to see only items needing attention.</p>
<p>
Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report.
The <strong>Low Stock</strong> stat card at the top of the Inventory page is clickable &mdash; click it to instantly
filter the list to only items needing attention. Click it again (or clear the filter) to return to the full list.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
@@ -59,6 +59,7 @@
<li class="mb-2">Select the customer and then select the job this invoice is for.</li>
<li class="mb-2">Add or adjust line items as needed.</li>
<li class="mb-2">Set the invoice date, due date, and any notes.</li>
<li class="mb-2">Optionally enter a <strong>Project Name</strong>. When creating from a job, this pre-fills from the job's project name automatically.</li>
<li class="mb-2">Click <strong>Save Invoice</strong>.</li>
</ol>
@@ -56,6 +56,7 @@
<li class="mb-2">Choose a <strong>Priority</strong> &mdash; Normal is the default; see the Job Priority section below for all levels.</li>
<li class="mb-2">Optionally assign a <strong>Worker</strong> from your shop workers list.</li>
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
<li class="mb-2">Optionally enter a <strong>Project Name</strong> &mdash; a short label that groups related jobs (e.g., &ldquo;Spring Fleet Refresh&rdquo;). It appears on the job, its invoice, and printed work orders.</li>
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
<li class="mb-2">
@@ -553,6 +554,33 @@
</ol>
</section>
<section id="clone-job" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-copy text-primary me-2"></i>Cloning a Job
</h2>
<p>
If you need to create a new job that is identical or very similar to one you have already done,
use the <strong>Clone</strong> button on the Job Details page. This saves you from re-entering
all the line items and coatings from scratch.
</p>
<p>Cloning copies the following to a brand-new Pending job:</p>
<ul class="mb-3">
<li>Customer and all job settings (description, PO number, project name, special instructions, tags, priority, discount, oven settings)</li>
<li>All line items with their coatings, colors, prep services, and pricing</li>
</ul>
<p>The following are <strong>not</strong> copied:</p>
<ul class="mb-3">
<li>Scheduled date, due date &mdash; you set these on the new job</li>
<li>Assigned worker</li>
<li>Photos, job notes, and time entries</li>
<li>Invoice and payment records</li>
</ul>
<p>
After cloning, the new job opens directly so you can review it, adjust dates, and save.
A new unique job number (<code>JOB-YYMM-####</code>) is generated automatically.
</p>
</section>
<section id="shop-display-and-board" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-display text-primary me-2"></i>Shop Display and Priority Board
@@ -827,6 +855,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
<a class="nav-link py-1 px-3 small text-body" href="#clone-job">Cloning a Job</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-display-and-board">Shop Display &amp; Priority Board</a>
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
@@ -74,6 +74,7 @@
</li>
<li class="mb-2">Set the <strong>Quote Date</strong> (defaults to today) and the <strong>Expiry Date</strong> (defaults to the system's configured validity period).</li>
<li class="mb-2">Add a <strong>Subject</strong> or description to identify the work being quoted.</li>
<li class="mb-2">Optionally enter a <strong>Project Name</strong> &mdash; a short label that groups related work (e.g., &ldquo;Fleet Refresh Q2&rdquo;). It appears on the quote PDF and carries over to the job and invoice when converted.</li>
<li class="mb-2">Add one or more <strong>Line Items</strong> &mdash; see the Quote Items section below for item types.</li>
<li class="mb-2">Add any <strong>Notes</strong> for the customer (these appear on the printed quote).</li>
<li class="mb-2">Add any internal <strong>Notes</strong> that are for your team only.</li>
@@ -180,6 +180,132 @@ async function removePreferredPowder(customerId, itemId) {
}
}
// ── Customer Contacts ──────────────────────────────────────────────────────
function openAddContactModal() {
document.getElementById('contactId').value = '0';
document.getElementById('contactModalTitle').textContent = 'Add Contact';
document.getElementById('contactFirstName').value = '';
document.getElementById('contactLastName').value = '';
document.getElementById('contactTitle').value = '';
document.getElementById('contactRole').value = '';
document.getElementById('contactEmail').value = '';
document.getElementById('contactPhone').value = '';
document.getElementById('contactMobilePhone').value = '';
document.getElementById('contactNotes').value = '';
document.getElementById('contactModalError').classList.add('d-none');
}
async function editContact(customerId, contactId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/GetContact/${customerId}?contactId=${contactId}`);
const data = await res.json();
if (!data.success) { toastr.error('Could not load contact.'); return; }
const c = data.contact;
document.getElementById('contactId').value = c.id;
document.getElementById('contactModalTitle').textContent = 'Edit Contact';
document.getElementById('contactFirstName').value = c.firstName ?? '';
document.getElementById('contactLastName').value = c.lastName ?? '';
document.getElementById('contactTitle').value = c.title ?? '';
document.getElementById('contactRole').value = c.contactRole ?? '';
document.getElementById('contactEmail').value = c.email ?? '';
document.getElementById('contactPhone').value = c.phone ?? '';
document.getElementById('contactMobilePhone').value = c.mobilePhone ?? '';
document.getElementById('contactNotes').value = c.notes ?? '';
document.getElementById('contactModalError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('contactModal')).show();
} catch {
toastr.error('An error occurred loading the contact.');
}
}
async function saveContact(customerId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const contactId = parseInt(document.getElementById('contactId').value ?? '0', 10);
const firstName = document.getElementById('contactFirstName').value.trim();
if (!firstName) {
const err = document.getElementById('contactModalError');
err.textContent = 'First name is required.';
err.classList.remove('d-none');
return;
}
const params = new URLSearchParams({
FirstName: firstName,
LastName: document.getElementById('contactLastName').value.trim(),
Title: document.getElementById('contactTitle').value.trim(),
ContactRole: document.getElementById('contactRole').value,
Email: document.getElementById('contactEmail').value.trim(),
Phone: document.getElementById('contactPhone').value.trim(),
MobilePhone: document.getElementById('contactMobilePhone').value.trim(),
Notes: document.getElementById('contactNotes').value.trim(),
});
const isEdit = contactId > 0;
if (isEdit) { params.append('Id', contactId); params.append('CustomerId', customerId); }
const url = isEdit ? `/Customers/UpdateContact/${customerId}` : `/Customers/AddContact/${customerId}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: params.toString()
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('contactModal'))?.hide();
const tbody = document.getElementById('contacts-table-body');
const placeholder = document.getElementById('no-contacts-placeholder');
if (placeholder) placeholder.remove();
if (isEdit) {
const existing = tbody.querySelector(`tr[data-contact-id="${contactId}"]`);
if (existing) existing.outerHTML = data.rowHtml;
else tbody.insertAdjacentHTML('beforeend', data.rowHtml);
} else {
tbody.insertAdjacentHTML('beforeend', data.rowHtml);
}
toastr.success(isEdit ? 'Contact updated.' : 'Contact added.');
} else {
const err = document.getElementById('contactModalError');
err.textContent = data.message || 'An error occurred.';
err.classList.remove('d-none');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function deleteContact(customerId, contactId) {
if (!confirm('Delete this contact?')) return;
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/DeleteContact/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `contactId=${contactId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`tr[data-contact-id="${contactId}"]`)?.remove();
const tbody = document.getElementById('contacts-table-body');
if (tbody && tbody.querySelectorAll('tr[data-contact-id]').length === 0)
tbody.insertAdjacentHTML('afterbegin', '<tr id="no-contacts-placeholder"><td colspan="4" class="text-muted small px-3 py-2">No additional contacts.</td></tr>');
toastr.success('Contact deleted.');
} else {
toastr.error(data.message || 'Could not delete contact.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section');
if (!section) return;