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:
@@ -55,6 +55,10 @@ public class Job : BaseEntity
|
|||||||
public int? IntakePartCount { get; set; }
|
public int? IntakePartCount { get; set; }
|
||||||
public string? IntakeCheckedByUserId { get; set; }
|
public string? IntakeCheckedByUserId { get; set; }
|
||||||
|
|
||||||
|
// Quote snapshot — UpdatedAt of the source quote at the moment this job was created from it.
|
||||||
|
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||||
|
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||||
|
|
||||||
// Rework tracking
|
// Rework tracking
|
||||||
public bool IsReworkJob { get; set; }
|
public bool IsReworkJob { get; set; }
|
||||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||||
|
|||||||
Generated
+9328
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobQuoteSnapshotUpdatedAt : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "QuoteSnapshotUpdatedAt",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteSnapshotUpdatedAt",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -488,6 +488,19 @@ public class JobsController : Controller
|
|||||||
.OrderBy(c => c.Text)
|
.OrderBy(c => c.Text)
|
||||||
.ToList();
|
.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);
|
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||||
@@ -3618,6 +3631,192 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
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 ─────────────────────────────────────────────────
|
// ── Job Costing Breakdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2991,7 +2991,8 @@ public class QuotesController : Controller
|
|||||||
DiscountValue = quote.DiscountValue,
|
DiscountValue = quote.DiscountValue,
|
||||||
DiscountReason = quote.DiscountReason,
|
DiscountReason = quote.DiscountReason,
|
||||||
CompanyId = quote.CompanyId,
|
CompanyId = quote.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.Jobs.AddAsync(job);
|
await _unitOfWork.Jobs.AddAsync(job);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
ViewData["Title"] = $"Job {Model.JobNumber}";
|
ViewData["Title"] = $"Job {Model.JobNumber}";
|
||||||
ViewData["PageIcon"] = "bi-briefcase";
|
ViewData["PageIcon"] = "bi-briefcase";
|
||||||
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel;
|
||||||
|
bool quoteUpdated = ViewBag.QuoteUpdatedAfterConversion == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -39,6 +40,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (quoteUpdated)
|
||||||
|
{
|
||||||
|
bool canResync = ViewBag.CanResyncFromQuote == true;
|
||||||
|
<div class="alert alert-warning alert-permanent mb-4">
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold mb-1">Source quote was updated on @(((DateTime)ViewBag.QuoteUpdatedAt).ToString("MMM d, yyyy"))</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<a asp-controller="Quotes" asp-action="Details" asp-route-id="@ViewBag.SourceQuoteId" class="alert-link">@ViewBag.SourceQuoteNumber</a>
|
||||||
|
was edited after this job was created.
|
||||||
|
@if (canResync)
|
||||||
|
{
|
||||||
|
<span>Re-sync to replace job items and pricing with the latest quote, or dismiss if you've already handled it manually.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Shop work has started — review the quote and apply any changes manually.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
@if (canResync)
|
||||||
|
{
|
||||||
|
<form asp-action="ResyncFromQuote" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-warning fw-semibold"
|
||||||
|
onclick="return confirm('This will replace all job items and pricing with the current quote. Continue?')">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Re-sync from Quote
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<form asp-action="DismissQuoteChangedBanner" asp-route-id="@Model.Id" method="post" class="d-inline">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-check me-1"></i>Dismiss
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (guidedActivationCallout?.Show == true)
|
@if (guidedActivationCallout?.Show == true)
|
||||||
{
|
{
|
||||||
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
<div class="alert alert-primary alert-permanent border-0 shadow-sm mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user