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:
@@ -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