Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges - Customer Notes log: inline add/delete notes with important flag, AJAX-backed - Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions - Preferred Powders per customer: typeahead inventory search, AJAX add/remove - CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic - Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user