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
@@ -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} &mdash; 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.