Add quote-changed banner with re-sync to job details

When a source quote is edited after a job was created from it, the job
details page now shows a warning banner with the date of the change and
a link to the quote. Two actions are offered:

- Re-sync from Quote: replaces all job items, coats, prep services, and
  pricing from the current quote. Only available while the job is still
  in a pre-production status (Pending, Quoted, Approved); hidden once
  shop work has started (InPreparation or beyond).
- Dismiss: acknowledges the change without altering the job, clearing
  the banner by advancing the stored snapshot timestamp.

Implemented via Job.QuoteSnapshotUpdatedAt (new nullable column), set at
quote→job conversion time. The banner fires when quote.UpdatedAt exceeds
this baseline. Migration: AddJobQuoteSnapshotUpdatedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 18:02:46 -04:00
parent ac3e4452b2
commit 9221fcc783
6 changed files with 9648 additions and 1 deletions
@@ -488,6 +488,19 @@ public class JobsController : Controller
.OrderBy(c => c.Text)
.ToList();
// Banner: warn if the source quote was edited after this job was created from it.
if (job.Quote != null && job.QuoteSnapshotUpdatedAt.HasValue &&
job.Quote.UpdatedAt.HasValue && job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt)
{
ViewBag.QuoteUpdatedAfterConversion = true;
ViewBag.QuoteUpdatedAt = job.Quote.UpdatedAt.Value;
ViewBag.SourceQuoteId = job.QuoteId;
ViewBag.SourceQuoteNumber = job.Quote.QuoteNumber;
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "PENDING", "QUOTED", "APPROVED" };
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
}
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
&& jobPrefs?.FirstWorkflowCompleted == false)
@@ -3618,6 +3631,192 @@ public class JobsController : Controller
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary>
/// Dismisses the "source quote was updated" banner by advancing QuoteSnapshotUpdatedAt
/// to match the quote's current UpdatedAt. No job data changes — the user is acknowledging
/// they have reviewed the quote manually.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DismissQuoteChangedBanner(int id)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false, j => j.Quote!);
if (job == null) return NotFound();
job.QuoteSnapshotUpdatedAt = job.Quote?.UpdatedAt ?? job.Quote?.CreatedAt;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Re-syncs a job's items and pricing from its source quote, but only while the job is
/// still in a pre-production status (PENDING, QUOTED, APPROVED). Once shop work has
/// started (IN_PREPARATION or beyond) the button is hidden and this endpoint returns 400
/// as a safety guard. Soft-deletes all current job items, then re-copies items, coats,
/// and prep services from the quote — identical logic to the initial quote→job conversion.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResyncFromQuote(int id)
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
if (job == null) return NotFound();
// Guard: only allow re-sync while job is pre-production
var preProductionCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "PENDING", "QUOTED", "APPROVED" };
if (!preProductionCodes.Contains(job.JobStatus?.StatusCode ?? ""))
{
TempData["Error"] = "Re-sync is only available before shop work has started.";
return RedirectToAction(nameof(Details), new { id });
}
if (!job.QuoteId.HasValue)
{
TempData["Error"] = "This job has no linked quote to sync from.";
return RedirectToAction(nameof(Details), new { id });
}
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// Soft-delete all current job items and their coats
var existingItems = job.JobItems.Where(ji => !ji.IsDeleted).ToList();
foreach (var item in existingItems)
{
var coats = await _unitOfWork.JobItemCoats.FindAsync(c => c.JobItemId == item.Id && !c.IsDeleted);
foreach (var coat in coats)
await _unitOfWork.JobItemCoats.SoftDeleteAsync(coat.Id);
await _unitOfWork.JobItems.SoftDeleteAsync(item.Id);
}
// Soft-delete all job-level prep services
var existingPrep = await _unitOfWork.JobPrepServices.FindAsync(p => p.JobId == id && !p.IsDeleted);
foreach (var ps in existingPrep)
await _unitOfWork.JobPrepServices.SoftDeleteAsync(ps.Id);
await _unitOfWork.SaveChangesAsync();
// Load quote items with full coat + prep-service data
var quote = job.Quote!;
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value);
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{
var firstCoat = quoteItem.Coats?.OrderBy(c => c.Sequence).FirstOrDefault();
var jobItem = new JobItem
{
JobId = id,
Description = quoteItem.Description,
Quantity = quoteItem.Quantity,
ColorName = firstCoat?.ColorName,
ColorCode = firstCoat?.ColorCode,
Finish = firstCoat?.Finish,
SurfaceArea = quoteItem.SurfaceAreaSqFt,
SurfaceAreaSqFt = quoteItem.SurfaceAreaSqFt,
CatalogItemId = quoteItem.CatalogItemId,
IsGenericItem = quoteItem.IsGenericItem,
IsLaborItem = quoteItem.IsLaborItem,
IsSalesItem = quoteItem.IsSalesItem,
Sku = quoteItem.Sku,
ManualUnitPrice = quoteItem.ManualUnitPrice,
PowderCostOverride = quoteItem.PowderCostOverride,
UnitPrice = quoteItem.UnitPrice,
TotalPrice = quoteItem.TotalPrice,
LaborCost = quoteItem.TotalPrice * 0.4m,
RequiresSandblasting = quoteItem.RequiresSandblasting,
RequiresMasking = quoteItem.RequiresMasking,
EstimatedMinutes = quoteItem.EstimatedMinutes,
Notes = quoteItem.Notes,
Complexity = quoteItem.Complexity,
AiTags = quoteItem.AiTags,
AiPredictionId = quoteItem.AiPredictionId,
IncludePrepCost = !quoteItem.CatalogItemId.HasValue,
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.JobItems.AddAsync(jobItem);
await _unitOfWork.SaveChangesAsync();
if (quoteItem.Coats != null)
{
foreach (var quoteCoat in quoteItem.Coats.OrderBy(c => c.Sequence))
{
string colorName = quoteCoat.ColorName;
string colorCode = quoteCoat.ColorCode;
string finish = quoteCoat.Finish;
if (quoteCoat.InventoryItemId.HasValue && quoteCoat.InventoryItem != null)
{
colorName = quoteCoat.InventoryItem.Name;
colorCode = quoteCoat.InventoryItem.ColorCode;
finish = quoteCoat.InventoryItem.Finish;
}
var cov = quoteCoat.CoverageSqFtPerLb > 0 ? quoteCoat.CoverageSqFtPerLb : 30m;
var eff = quoteCoat.TransferEfficiency > 0 ? quoteCoat.TransferEfficiency / 100m : 0.65m;
var powderToOrder = (quoteCoat.PowderToOrder > 0)
? quoteCoat.PowderToOrder
: (quoteItem.SurfaceAreaSqFt > 0
? Math.Round((quoteItem.SurfaceAreaSqFt * quoteItem.Quantity) / (cov * eff), 2)
: (decimal?)null);
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = jobItem.Id,
CoatName = quoteCoat.CoatName,
Sequence = quoteCoat.Sequence,
InventoryItemId = quoteCoat.InventoryItemId,
ColorName = colorName,
VendorId = quoteCoat.VendorId,
ColorCode = colorCode,
Finish = finish,
CoverageSqFtPerLb = quoteCoat.CoverageSqFtPerLb,
TransferEfficiency = quoteCoat.TransferEfficiency,
PowderCostPerLb = quoteCoat.PowderCostPerLb,
PowderToOrder = powderToOrder,
Notes = quoteCoat.Notes,
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
}
}
}
await _unitOfWork.SaveChangesAsync();
// Aggregate prep services from all quote items and copy to job
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
var itemPrepServices = await _unitOfWork.QuoteItemPrepServices.FindAsync(
ps => quoteItemIds.Contains(ps.QuoteItemId));
foreach (var prepServiceId in itemPrepServices.Select(ps => ps.PrepServiceId).Distinct())
{
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
{
JobId = id,
PrepServiceId = prepServiceId,
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
}
// Update pricing from quote and advance the snapshot so banner clears
job.QuotedPrice = quote.Total;
job.FinalPrice = quote.Total;
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
await _unitOfWork.CompleteAsync();
});
_logger.LogInformation("Job {JobId} re-synced from quote {QuoteId}", id, job.QuoteId);
TempData["Success"] = "Job items and pricing re-synced from the source quote.";
return RedirectToAction(nameof(Details), new { id });
}
// ── Job Costing Breakdown ─────────────────────────────────────────────────
/// <summary>
@@ -2991,7 +2991,8 @@ public class QuotesController : Controller
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow,
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
};
await _unitOfWork.Jobs.AddAsync(job);