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:
2026-06-09 19:59:32 -04:00
parent 7cbae31916
commit 711cd01cd3
14 changed files with 12725 additions and 22 deletions
@@ -328,6 +328,121 @@
</div>
</div>
}
<!-- Customer Notes -->
@{
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-sticky me-2 text-primary"></i>Internal Notes
</h5>
</div>
<div class="card-body p-0">
<div id="customer-notes-list">
@if (customerNotes != null && customerNotes.Count > 0)
{
@foreach (var note in customerNotes)
{
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
<div class="flex-grow-1">
@if (note.IsImportant)
{
<span class="text-warning me-1" title="Important">&#9733;</span>
}
<span class="note-text small">@note.Note</span>
<div class="text-muted" style="font-size:0.75rem;">
@(note.CreatedBy ?? "Staff") &mdash; @note.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy h:mm tt")
</div>
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0 align-self-start"
onclick="deleteCustomerNote(@Model.Id, @note.Id)" title="Delete note">
<i class="bi bi-trash"></i>
</button>
</div>
}
}
else
{
<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>
}
</div>
<div class="px-3 py-3 border-top bg-light">
<div class="mb-2">
<textarea id="newNoteText" class="form-control form-control-sm" rows="2"
placeholder="Add an internal note..." maxlength="2000"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check form-check-sm mb-0">
<input class="form-check-input" type="checkbox" id="newNoteImportant">
<label class="form-check-label small" for="newNoteImportant">
<span class="text-warning">&#9733;</span> Mark important
</label>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addCustomerNote(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Note
</button>
</div>
</div>
</div>
</div>
<!-- Recent Activity Timeline -->
@{
var timeline = ViewBag.Timeline as List<PowderCoating.Application.DTOs.Customer.CustomerTimelineEventDto>;
}
@if (timeline != null && timeline.Count > 0)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Recent Activity
</h5>
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
View All
</a>
</div>
<div class="card-body p-0">
@foreach (var ev in timeline)
{
var hasLink = ev.LinkController != null && ev.EntityId.HasValue;
var rowTag = hasLink ? "a" : "div";
var href = hasLink
? Url.Action(ev.LinkAction, ev.LinkController, new { id = ev.EntityId })
: null;
<div class="d-flex align-items-start gap-3 px-3 py-3 border-bottom @(hasLink ? "timeline-row" : "")">
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 mt-1"
style="width:34px;height:34px;background:var(--bs-@(ev.BadgeColor)-bg-subtle,#f0f0f0);">
<i class="bi @ev.Icon text-@ev.BadgeColor" style="font-size:0.9rem;"></i>
</div>
<div class="flex-grow-1 min-width-0">
@if (hasLink)
{
<a asp-controller="@ev.LinkController" asp-action="@ev.LinkAction" asp-route-id="@ev.EntityId"
class="fw-semibold text-decoration-none text-body d-block text-truncate">@ev.Title</a>
}
else
{
<span class="fw-semibold d-block text-truncate">@ev.Title</span>
}
@if (!string.IsNullOrEmpty(ev.Subtitle))
{
<span class="text-muted small d-block text-truncate">@ev.Subtitle</span>
}
<span class="text-muted" style="font-size:0.75rem;">@ev.Date.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</span>
</div>
@if (ev.Amount.HasValue)
{
<div class="text-end flex-shrink-0">
<span class="fw-semibold small">@ev.Amount.Value.ToString("C")</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
<!-- Right Column - Statistics -->
@@ -378,6 +493,41 @@
</div>
</div>
<!-- Outstanding Pickups -->
@{
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
}
@if (pendingPickups != null && pendingPickups.Count > 0)
{
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
<h5 class="mb-0 fw-semibold text-warning-emphasis">
<i class="bi bi-truck me-2"></i>Ready for Pickup
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
</h5>
</div>
<div class="card-body p-0">
@foreach (var pickup in pendingPickups)
{
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
<div class="flex-grow-1">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
@if (!string.IsNullOrEmpty(pickup.Description))
{
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
}
</div>
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
</span>
</div>
}
</div>
</div>
}
<!-- Store Credit History -->
@{
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
@@ -430,33 +580,146 @@
</div>
}
<!-- Activity -->
<!-- Customer Stats -->
@{
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStats != null)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
</h5>
</div>
<div class="card-body">
<!-- Jobs row -->
<div class="row g-2 mb-3">
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
<div class="text-muted small mb-1">Total Jobs</div>
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
@if (crmStats.ActiveJobs > 0)
{
<span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
@crmStats.ActiveJobs active
</span>
}
</div>
<div class="col-6 text-center p-2">
<div class="text-muted small mb-1">Avg Job Value</div>
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
</div>
</div>
<hr class="my-2" />
<!-- Revenue row -->
<div class="row g-2 mb-2">
<div class="col-6">
<div class="text-muted small mb-1">Lifetime Revenue</div>
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
</div>
<div class="col-6">
<div class="text-muted small mb-1">Total Collected</div>
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
</div>
</div>
<hr class="my-2" />
<!-- Footer stats -->
<div class="d-flex justify-content-between text-muted small mt-2">
<span>
@if (crmStats.DaysSinceLastJob.HasValue)
{
<i class="bi bi-calendar-check me-1"></i>
@if (crmStats.DaysSinceLastJob == 0)
{
<span>Last job today</span>
}
else
{
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
}
}
else
{
<span>No jobs yet</span>
}
</span>
<span>
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
</span>
</div>
<div class="text-muted small mt-1">
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
</div>
</div>
</div>
}
<!-- Preferred Powders -->
@{
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-clock-history me-2 text-primary"></i>Activity
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="text-muted small mb-1">Last Contact</label>
<p class="mb-0">
@if (Model.LastContactDate.HasValue)
<div class="card-body p-0">
<div id="preferred-powders-list">
@if (preferredPowders != null && preferredPowders.Count > 0)
{
@foreach (var p in preferredPowders)
{
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1">
<span class="small fw-semibold">@p.InventoryItem.Name</span>
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
{
<span class="text-muted small ms-1">&mdash; @p.InventoryItem.ColorName</span>
}
@if (!string.IsNullOrEmpty(p.Notes))
{
<div class="text-muted" style="font-size:0.75rem;">@p.Notes</div>
}
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0"
onclick="removePreferredPowder(@Model.Id, @p.InventoryItemId)"
title="Remove from preferred">&times;</button>
</div>
}
else
{
<span class="text-muted">No contact recorded</span>
}
</p>
}
else
{
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
}
</div>
<div>
<label class="text-muted small mb-1">Customer Since</label>
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
<div class="px-3 py-3 border-top bg-light position-relative">
<div class="mb-2">
<input type="text" id="powderSearchInput" class="form-control form-control-sm"
placeholder="Search powder by name or SKU..."
oninput="searchInventoryItems(this.value)"
autocomplete="off" />
<input type="hidden" id="selectedPowderId" />
<div id="powderSearchResults" class="dropdown-menu w-100 show p-0"
style="display:none!important;position:absolute;z-index:1000;"
onfocusout=""></div>
</div>
<div class="mb-2">
<input type="text" id="powderNotes" class="form-control form-control-sm"
placeholder="Optional notes (e.g. &quot;customer prefers this for wheels&quot;)"
maxlength="500" />
</div>
<button type="button" class="btn btn-sm btn-primary w-100"
onclick="addPreferredPowder(@Model.Id)">
<i class="bi bi-plus-circle me-1"></i>Add Powder
</button>
</div>
</div>
</div>
<style>
#powderSearchResults:not(:empty) { display:block!important; max-height:200px; overflow-y:auto; }
</style>
<!-- Quick Actions -->
<div class="card border-0 shadow-sm">
@@ -482,6 +745,17 @@
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
<i class="bi bi-plus-circle me-2"></i>New Job
</a>
@{
var crmStatsForActions = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
}
@if (crmStatsForActions?.LastJobId != null)
{
<a asp-controller="Jobs" asp-action="CloneJob" asp-route-id="@crmStatsForActions.LastJobId"
class="btn btn-outline-secondary"
title="Create a new job pre-filled with the last job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
</a>
}
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
<i class="bi bi-file-text me-2"></i>New Quote
</a>
@@ -19,6 +19,10 @@
title="Save this job as a reusable template">
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
</button>
<a asp-action="CloneJob" asp-route-id="@Model.Id" class="btn btn-outline-secondary"
title="Create a new job pre-filled with this job&apos;s items and settings">
<i class="bi bi-arrow-repeat me-2"></i>Clone Job
</a>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-2"></i>Edit
</a>