Add rework pricing type (Fixed vs Per-Item) and inline rework flow on Job Details
Adds a PricingType enum to ReworkRecord (FixedPrice | PerItem), surfaces the choice in the rework modal on Job Details, and wires the resulting unit/total price display. Includes migration AddReworkPricingType, updated repository query for rework history, and help article updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2407,6 +2407,28 @@ public class JobsController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
// When a rework job reaches a terminal status, close out the linked ReworkRecord
|
||||
// on the original job so the shop doesn't have to do it manually.
|
||||
// Cancelled → WrittenOff; any other terminal → Resolved.
|
||||
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
|
||||
{
|
||||
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
|
||||
foreach (var rr in linkedRecords)
|
||||
{
|
||||
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
|
||||
continue;
|
||||
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
|
||||
? ReworkStatus.WrittenOff
|
||||
: ReworkStatus.Resolved;
|
||||
rr.ResolvedDate ??= DateTime.UtcNow;
|
||||
rr.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
|
||||
}
|
||||
if (linkedRecords.Any())
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Notify customer on status change (only if user opted in)
|
||||
if (request.SendEmail && newStatus != null)
|
||||
{
|
||||
@@ -3528,10 +3550,13 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a rework event against a job item (e.g. defect found during QC).
|
||||
/// Automatically creates a new linked rework Job so the repair work can be tracked
|
||||
/// through the same job lifecycle. The rework job inherits the original job's customer,
|
||||
/// oven, and items so the shop has a complete specification to work from.
|
||||
/// Records a rework event against a job. Optionally creates a linked rework job so the
|
||||
/// repair can flow through the full shop lifecycle. When creating a rework job:
|
||||
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
|
||||
/// - Only items selected by the user are copied (partial rework support)
|
||||
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
|
||||
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
|
||||
/// - Job starts at the first non-Pending status in the company's workflow
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||
@@ -3540,95 +3565,207 @@ public class JobsController : Controller
|
||||
if (job == null) return NotFound();
|
||||
|
||||
var companyId = job.CompanyId;
|
||||
Job? reworkJob = null;
|
||||
|
||||
// Generate rework job number
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
||||
var year = DateTime.Now.ToString("yy");
|
||||
var month = DateTime.Now.ToString("MM");
|
||||
var prefix = $"JOB-{year}{month}-";
|
||||
var maxNum = allJobs
|
||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
||||
.DefaultIfEmpty(0).Max();
|
||||
|
||||
var reworkJob = pendingStatus != null ? new Job
|
||||
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
|
||||
{
|
||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
||||
CustomerId = job.CustomerId,
|
||||
Description = $"REWORK: {job.Description}",
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
IsReworkJob = true,
|
||||
OriginalJobId = job.Id,
|
||||
SpecialInstructions = $"Rework of {job.JobNumber}.",
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
} : null;
|
||||
|
||||
if (reworkJob != null)
|
||||
{
|
||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Copy items: specific item if flagged, otherwise all items
|
||||
var itemsToCopy = dto.JobItemId.HasValue
|
||||
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
|
||||
: job.JobItems.ToList();
|
||||
|
||||
foreach (var item in itemsToCopy)
|
||||
var typeLabel = dto.ReworkType switch
|
||||
{
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||
ReworkType.InternalDefect => "Internal Defect",
|
||||
ReworkType.CustomerWarranty => "Customer Warranty",
|
||||
ReworkType.CustomerDamage => "Customer Damage",
|
||||
_ => dto.ReworkType.ToString()
|
||||
};
|
||||
var reasonLabel = dto.Reason switch
|
||||
{
|
||||
ReworkReason.AdhesionFailure => "Adhesion Failure",
|
||||
ReworkReason.Contamination => "Contamination",
|
||||
ReworkReason.ColorMismatch => "Color Mismatch",
|
||||
ReworkReason.RunsSags => "Runs / Sags",
|
||||
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
|
||||
ReworkReason.OvenIssue => "Oven Issue",
|
||||
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
|
||||
ReworkReason.HandlingDamage => "Handling Damage",
|
||||
_ => "Other"
|
||||
};
|
||||
var pricingLabel = dto.ReworkPricingType.Value switch
|
||||
{
|
||||
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||
_ => ""
|
||||
};
|
||||
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
|
||||
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
}
|
||||
|
||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
var currentUserId = _userManager.GetUserId(User);
|
||||
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
|
||||
}
|
||||
|
||||
var record = new ReworkRecord
|
||||
{
|
||||
JobId = dto.JobId,
|
||||
JobItemId = dto.JobItemId,
|
||||
ReworkType = dto.ReworkType,
|
||||
Reason = dto.Reason,
|
||||
JobId = dto.JobId,
|
||||
JobItemId = dto.JobItemId,
|
||||
ReworkType = dto.ReworkType,
|
||||
Reason = dto.Reason,
|
||||
DefectDescription = dto.DefectDescription,
|
||||
DiscoveredBy = dto.DiscoveredBy,
|
||||
DiscoveredDate = dto.DiscoveredDate,
|
||||
ReportedByName = dto.ReportedByName,
|
||||
DiscoveredBy = dto.DiscoveredBy,
|
||||
DiscoveredDate = dto.DiscoveredDate,
|
||||
ReportedByName = dto.ReportedByName,
|
||||
EstimatedReworkCost = dto.EstimatedReworkCost,
|
||||
IsBillableToCustomer = dto.IsBillableToCustomer,
|
||||
BillingNotes = dto.BillingNotes,
|
||||
ReworkJobId = reworkJob?.Id,
|
||||
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
BillingNotes = dto.BillingNotes,
|
||||
ReworkPricingType = dto.ReworkPricingType,
|
||||
ReworkJobId = reworkJob?.Id,
|
||||
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.ReworkRecords.AddAsync(record);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Reload with navigation for response
|
||||
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
||||
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a linked rework Job from an existing rework record that was saved without one.
|
||||
/// Uses sub-number format and applies the specified pricing attribution.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
||||
{
|
||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||
if (reworkRecord == null) return NotFound();
|
||||
|
||||
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
|
||||
if (originalJob == null) return NotFound();
|
||||
|
||||
var companyId = originalJob.CompanyId;
|
||||
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
|
||||
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
|
||||
|
||||
var pricingLabel = pricingType switch
|
||||
{
|
||||
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||
_ => ""
|
||||
};
|
||||
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
|
||||
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
|
||||
var currentUserId = _userManager.GetUserId(User);
|
||||
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
|
||||
|
||||
reworkRecord.ReworkJobId = reworkJob.Id;
|
||||
reworkRecord.ReworkPricingType = pricingType;
|
||||
reworkRecord.Status = ReworkStatus.InProgress;
|
||||
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
|
||||
/// copies the specified items (with coats and prep services), applies pricing attribution,
|
||||
/// sets descriptive job description from the rework record data, and auto-records intake
|
||||
/// (parts are already on hand when rework is logged).
|
||||
/// Called by both AddReworkRecord and CreateReworkJob.
|
||||
/// </summary>
|
||||
private async Task<Job> BuildReworkJobAsync(
|
||||
Job originalJob,
|
||||
List<int> itemIds,
|
||||
ReworkPricingType pricingType,
|
||||
int companyId,
|
||||
string reworkDescription,
|
||||
string? checkedByUserId)
|
||||
{
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
|
||||
// First non-Pending status by workflow order
|
||||
var firstActiveStatus = statuses
|
||||
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
|
||||
.OrderBy(s => s.DisplayOrder)
|
||||
.First();
|
||||
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||
|
||||
// Sub-number: {parentJobNumber}-R{n+1}
|
||||
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
|
||||
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
||||
|
||||
var reworkJob = new Job
|
||||
{
|
||||
JobNumber = reworkNumber,
|
||||
CustomerId = originalJob.CustomerId,
|
||||
Description = reworkDescription,
|
||||
JobStatusId = firstActiveStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
IsReworkJob = true,
|
||||
OriginalJobId = originalJob.Id,
|
||||
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
|
||||
// Auto-intake: parts are already on hand when rework is logged
|
||||
IntakeDate = DateTime.UtcNow,
|
||||
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
|
||||
IntakeCheckedByUserId = checkedByUserId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
|
||||
var createdAtUtc = DateTime.UtcNow;
|
||||
|
||||
foreach (var item in itemsToCopy)
|
||||
{
|
||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||
|
||||
// Shop-fault rework jobs are done at no charge
|
||||
if (pricingType == ReworkPricingType.ShopFault)
|
||||
{
|
||||
newItem.UnitPrice = 0;
|
||||
newItem.ManualUnitPrice = 0;
|
||||
newItem.TotalPrice = 0;
|
||||
}
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||
|
||||
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
|
||||
}
|
||||
|
||||
// Set intake part count now that items are known
|
||||
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
|
||||
|
||||
// Write a pricing snapshot so the Details page and inline edit both work correctly
|
||||
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
|
||||
? 0m
|
||||
: itemsToCopy.Sum(i => i.TotalPrice);
|
||||
reworkJob.FinalPrice = itemsSubtotal;
|
||||
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||
{
|
||||
ItemsSubtotal = itemsSubtotal,
|
||||
SubtotalBeforeDiscount = itemsSubtotal,
|
||||
SubtotalAfterDiscount = itemsSubtotal,
|
||||
Total = itemsSubtotal
|
||||
});
|
||||
|
||||
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return reworkJob;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a rework record's status, resolution notes, cost, and billability.
|
||||
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
||||
@@ -3680,66 +3817,6 @@ public class JobsController : Controller
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new rework Job from an existing rework record and links them.
|
||||
/// The rework job is a lightweight clone of the original job — same customer, description, and
|
||||
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
|
||||
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
||||
{
|
||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||
if (reworkRecord == null) return NotFound();
|
||||
|
||||
var originalJob = reworkRecord.Job;
|
||||
var companyId = originalJob.CompanyId;
|
||||
|
||||
// Load status lookups to find Pending status
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
|
||||
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||
|
||||
// Generate job number
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
||||
var year = DateTime.Now.ToString("yy");
|
||||
var month = DateTime.Now.ToString("MM");
|
||||
var prefix = $"JOB-{year}{month}-";
|
||||
var maxNum = allJobs
|
||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
||||
.DefaultIfEmpty(0).Max();
|
||||
|
||||
var reworkJob = new Job
|
||||
{
|
||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
||||
CustomerId = originalJob.CustomerId,
|
||||
Description = $"REWORK: {originalJob.Description}",
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
IsReworkJob = true,
|
||||
OriginalJobId = originalJob.Id,
|
||||
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Link rework record to new job
|
||||
reworkRecord.ReworkJobId = reworkJob.Id;
|
||||
reworkRecord.Status = ReworkStatus.InProgress;
|
||||
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||
}
|
||||
|
||||
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -4311,7 +4388,13 @@ public class LogMaterialRequest
|
||||
public string TransactionType { get; set; } = "JobUsage";
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
||||
public class CreateReworkJobRequest
|
||||
{
|
||||
public int ReworkRecordId { get; set; }
|
||||
public List<int>? ItemIds { get; set; }
|
||||
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateWorkerAssignmentRequest
|
||||
{
|
||||
|
||||
@@ -475,12 +475,41 @@
|
||||
actual hours vs. estimated hours for costing and productivity analysis.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Rework (also called Redo)</h3>
|
||||
<p>
|
||||
If finished parts fail quality inspection or need to be re-coated, create a rework record
|
||||
from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
|
||||
color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
|
||||
issues over time.
|
||||
If a finished part fails quality inspection or a customer returns it damaged, open the original
|
||||
job’s Details page and use the <strong>Rework Log</strong> section to record it. Rework
|
||||
and redo mean the same thing throughout the system.
|
||||
</p>
|
||||
<p>Each entry captures the type (internal defect, customer damage, warranty), the reason (adhesion
|
||||
failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who
|
||||
discovered the issue, and pricing responsibility.</p>
|
||||
|
||||
<h4 class="h6 fw-semibold mt-3 mb-1">Pricing Responsibility</h4>
|
||||
<ul>
|
||||
<li><strong>Shop Fault — no charge:</strong> All copied item prices are set to $0.</li>
|
||||
<li><strong>Customer responsible — reduced rate:</strong> Prices are copied from the original job; edit them down after creation.</li>
|
||||
<li><strong>Customer responsible — full price:</strong> Prices are copied as-is.</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="h6 fw-semibold mt-3 mb-1">Creating a Rework Job</h4>
|
||||
<p>
|
||||
Toggle <strong>Parts are back — create a Rework Job</strong> at the top of the log form.
|
||||
Select the items that need to be redone and choose the pricing responsibility. The system will:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Create a new job with a sub-number (e.g., <code>JOB-2605-0001-R1</code>)</li>
|
||||
<li>Copy the selected items with their coats and prep services</li>
|
||||
<li>Auto-record intake — parts are already on hand when rework is logged</li>
|
||||
<li>Set the job description to the defect type, reason, and pricing so it is visible at the top of the job</li>
|
||||
</ul>
|
||||
|
||||
<h4 class="h6 fw-semibold mt-3 mb-1">Automatic Resolution</h4>
|
||||
<p>
|
||||
When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework
|
||||
record on the original job is automatically marked <strong>Resolved</strong> — no manual
|
||||
follow-up needed. If the rework job is <strong>Cancelled</strong>, the record is marked
|
||||
<strong>Written Off</strong> instead.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
|
||||
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<strong>Rework Job</strong> — This job was created to redo work from
|
||||
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
|
||||
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
|
||||
</a>.
|
||||
All costs for this redo are tracked here separately from the original job.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
|
||||
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
|
||||
@@ -2189,6 +2203,88 @@
|
||||
<div class="modal-body">
|
||||
<div id="reworkAddForm">
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- Step 1: Are parts back in the shop? -->
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
|
||||
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
|
||||
<i class="bi bi-briefcase me-1"></i>Parts are back — create a Rework Job in the shop
|
||||
</label>
|
||||
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
|
||||
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
|
||||
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
|
||||
<div id="rwItemCheckboxes">
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<div class="form-check">
|
||||
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
|
||||
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
|
||||
<label class="form-check-label" for="rwPricingShopFault">
|
||||
<strong>Shop Fault</strong>
|
||||
<span class="text-muted small d-block">Our mistake — rework job priced at $0.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
|
||||
<label class="form-check-label" for="rwPricingCustomerReduced">
|
||||
<strong>Customer — Reduced Rate</strong>
|
||||
<span class="text-muted small d-block">Customer caused it but we’re helping out — prices copied, edit after creation.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
|
||||
<label class="form-check-label" for="rwPricingCustomerFull">
|
||||
<strong>Customer — Full Price</strong>
|
||||
<span class="text-muted small d-block">Customer caused it — original pricing applies.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rwSpecificItemRow" class="col-md-6">
|
||||
<label class="form-label">Specific Item (optional)</label>
|
||||
<select class="form-select" id="rwJobItem">
|
||||
<option value="">– Whole Job –</option>
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<option value="@item.Id">@item.Description</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr class="my-0" />
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Type <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="rwType">
|
||||
@@ -2215,19 +2311,6 @@
|
||||
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Specific Item (optional)</label>
|
||||
<select class="form-select" id="rwJobItem">
|
||||
<option value="">– Whole Job –</option>
|
||||
@if (Model.Items != null)
|
||||
{
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<option value="@item.Id">@item.Description</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Discovered By</label>
|
||||
<select class="form-select" id="rwDiscoveredBy">
|
||||
@@ -2654,9 +2737,20 @@
|
||||
document.getElementById('rwBillingNotes').value = '';
|
||||
document.getElementById('rwReportedBy').value = '';
|
||||
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
|
||||
// Reset rework job creation section
|
||||
document.getElementById('rwCreateJobToggle').checked = false;
|
||||
document.getElementById('rwCreateJobOptions').style.display = 'none';
|
||||
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function toggleCreateJob(on) {
|
||||
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
|
||||
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
|
||||
if (!on) document.getElementById('rwJobItem').value = '';
|
||||
}
|
||||
|
||||
function openEdit(id) {
|
||||
editId = id;
|
||||
const r = records.find(x => x.id === id);
|
||||
@@ -2685,9 +2779,23 @@
|
||||
// Create
|
||||
const defect = document.getElementById('rwDefect').value.trim();
|
||||
if (!defect) { alert('Defect description is required.'); return; }
|
||||
|
||||
const createJob = document.getElementById('rwCreateJobToggle').checked;
|
||||
const selectedItemIds = createJob
|
||||
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
|
||||
: null;
|
||||
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
|
||||
|
||||
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
|
||||
alert('Select at least one item to include in the rework job.'); return;
|
||||
}
|
||||
if (createJob && !pricingRadio) {
|
||||
alert('Select who is responsible for this rework.'); return;
|
||||
}
|
||||
|
||||
const dto = {
|
||||
jobId: jid,
|
||||
jobItemId: document.getElementById('rwJobItem').value || null,
|
||||
jobItemId: createJob ? null : (document.getElementById('rwJobItem').value || null),
|
||||
reworkType: parseInt(document.getElementById('rwType').value),
|
||||
reason: parseInt(document.getElementById('rwReason').value),
|
||||
defectDescription: defect,
|
||||
@@ -2696,7 +2804,10 @@
|
||||
reportedByName: document.getElementById('rwReportedBy').value || null,
|
||||
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
|
||||
isBillableToCustomer: document.getElementById('rwBillable').checked,
|
||||
billingNotes: document.getElementById('rwBillingNotes').value || null
|
||||
billingNotes: document.getElementById('rwBillingNotes').value || null,
|
||||
createReworkJob: createJob,
|
||||
reworkJobItemIds: selectedItemIds,
|
||||
reworkPricingType: pricingRadio ? parseInt(pricingRadio.value) : null
|
||||
};
|
||||
const resp = await fetch('/Jobs/AddReworkRecord', {
|
||||
method: 'POST',
|
||||
@@ -2741,7 +2852,7 @@
|
||||
}
|
||||
|
||||
load();
|
||||
return { load, openAdd, openEdit, save, del };
|
||||
return { load, openAdd, openEdit, save, del, toggleCreateJob };
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -2906,8 +3017,8 @@
|
||||
|
||||
function updateTotals(total) {
|
||||
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '—';
|
||||
document.getElementById('totalHoursDisplay').textContent = fmt;
|
||||
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '—';
|
||||
document.getElementById('totalHoursDisplay').innerHTML = fmt;
|
||||
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '—';
|
||||
}
|
||||
|
||||
// -- Modal helpers -------------------------------------------------
|
||||
@@ -3245,3 +3356,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user