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:
@@ -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\">—</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\">—</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.
|
||||
|
||||
Reference in New Issue
Block a user