Merge duplicate powder lines on dashboard order queue

When multiple jobs need the same powder, the 'Powder in Queue to be
Ordered' panel now collapses them into a single line (summed lbs) rather
than showing one row per coat. 'Mark as Ordered' marks all contributing
coats at once and injects each into the 'Awaiting Receipt' panel
individually so per-coat receiving still works unchanged.

- Add PowderOrderJobRefDto; PowderOrderLineDto gains CoatIds + Jobs lists
  (scalar CoatId/JobId/etc. become computed accessors for backward compat)
- MapPowderOrderGroupsMerged: secondary GroupBy on (ColorName, ColorCode,
  Finish, SKU) within vendor group for the 'needed' panel
- MapPowderOrderGroups kept per-coat for the 'awaiting receipt' panel
- MarkPowderOrdered accepts comma-separated coatIds, returns coats array
- Dashboard view: Customer column loops job refs for merged rows; JS posts
  coatIds and iterates data.coats to populate awaiting-receipt panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:59:10 -04:00
parent a21c05f655
commit 8f11e00a0a
4 changed files with 282 additions and 525 deletions
@@ -228,12 +228,31 @@ public class PowderOrderVendorGroupDto
public List<PowderOrderLineDto> Lines { get; set; } = new();
}
public class PowderOrderLineDto
/// <summary>
/// One job's contribution to a merged powder order line.
/// </summary>
public class PowderOrderJobRefDto
{
public int CoatId { get; set; }
public int JobId { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public decimal LbsToOrder { get; set; }
}
public class PowderOrderLineDto
{
/// <summary>All coat IDs contributing to this line (&gt;1 when multiple jobs need the same powder).</summary>
public List<int> CoatIds { get; set; } = new();
/// <summary>Per-job breakdown; parallel to CoatIds.</summary>
public List<PowderOrderJobRefDto> Jobs { get; set; } = new();
// Convenience accessors for single-coat scenarios (the "placed" panel is always per-coat).
public int CoatId => CoatIds.FirstOrDefault();
public int JobId => Jobs.FirstOrDefault()?.JobId ?? 0;
public string JobNumber => Jobs.FirstOrDefault()?.JobNumber ?? string.Empty;
public string CustomerName => Jobs.FirstOrDefault()?.CustomerName ?? string.Empty;
public string CoatName { get; set; } = string.Empty;
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
@@ -266,7 +266,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
var powderOrderGroups = MapPowderOrderGroupsMerged(data.PowderOrdersNeeded);
// ---------------------------------------------------------------
// Powder orders placed
@@ -385,44 +385,53 @@ public class DashboardController : Controller
}
/// <summary>
/// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders
/// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct
/// CompanyId) before updating <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and
/// <c>PowderOrderedByUserId</c> on the coat record.
/// Marks one or more job-item coats as having their powder ordered. Called via AJAX from
/// the "Powder in Queue to be Ordered" panel. Accepts a comma-separated list of coat IDs
/// so that a merged line (multiple jobs needing the same powder) can be marked in one click.
/// Verifies company ownership for each coat via its parent job before updating
/// <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and <c>PowderOrderedByUserId</c>.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> MarkPowderOrdered(int coatId)
public async Task<IActionResult> MarkPowderOrdered(string coatIds)
{
try
{
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
var ids = coatIds?
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : 0)
.Where(id => id > 0)
.ToList() ?? new List<int>();
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
if (ids.Count == 0)
return Json(new { success = false, message = "No valid coat IDs provided." });
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var results = new List<object>();
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
return Json(new
foreach (var coatId in ids)
{
success = true,
coat = new
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null) continue;
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
continue;
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = currentUserId;
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
results.Add(new
{
coatId = coat.Id,
jobId = job?.Id,
jobNumber = job?.JobNumber,
customerName = job?.Customer?.CompanyName,
customerName = job?.Customer?.CompanyName ?? job?.Customer?.ContactFirstName ?? "Unknown",
colorName = coat.ColorName,
colorCode = coat.ColorCode,
finish = coat.Finish,
@@ -434,12 +443,16 @@ public class DashboardController : Controller
vendorId = vendor?.Id,
vendorName = vendor?.CompanyName ?? "No Vendor Assigned",
vendorPhone = vendor?.Phone
}
});
});
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true, coats = results });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId);
_logger.LogError(ex, "Error marking coats {CoatIds} as powder ordered", coatIds);
return Json(new { success = false, message = "An error occurred." });
}
}
@@ -876,6 +889,10 @@ public class DashboardController : Controller
}
}
/// <summary>
/// Projects per-coat rows into vendor-grouped DTOs one-line-per-coat.
/// Used for the "Awaiting Receipt" panel where each coat is received individually.
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
@@ -892,10 +909,11 @@ public class DashboardController : Controller
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatIds = new List<int> { l.CoatId },
Jobs = new List<PowderOrderJobRefDto>
{
new() { JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, LbsToOrder = l.LbsToOrder }
},
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
@@ -916,6 +934,68 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Like <see cref="MapPowderOrderGroups"/> but collapses coats for the same powder within
/// a vendor group into one line, summing lbs and accumulating coat IDs and job refs.
/// Used for the "Powder in Queue to be Ordered" panel so you order one batch per color.
/// Two coats are considered the same powder when ColorName, ColorCode, Finish, and SKU
/// all match (case- and whitespace-insensitive).
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroupsMerged(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(vendorGrp =>
{
var first = vendorGrp.First();
var mergedLines = vendorGrp
.GroupBy(l => (
ColorName: l.ColorName?.Trim().ToLowerInvariant() ?? "",
ColorCode: l.ColorCode?.Trim().ToLowerInvariant() ?? "",
Finish: l.Finish?.Trim().ToLowerInvariant() ?? "",
SKU: l.SKU?.Trim().ToLowerInvariant() ?? ""
))
.Select(powderGrp =>
{
var p = powderGrp.First();
return new PowderOrderLineDto
{
CoatIds = powderGrp.Select(l => l.CoatId).ToList(),
Jobs = powderGrp.Select(l => new PowderOrderJobRefDto
{
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
LbsToOrder = l.LbsToOrder
}).ToList(),
CoatName = p.CoatName,
ColorName = p.ColorName,
ColorCode = p.ColorCode,
Finish = p.Finish,
SKU = p.SKU,
LbsToOrder = powderGrp.Sum(l => l.LbsToOrder),
CostPerLb = p.CostPerLb,
HasInventoryItem = p.HasInventoryItem,
VendorId = p.VendorId
};
})
.OrderBy(l => l.ColorName)
.ThenBy(l => l.CoatName)
.ToList();
return new PowderOrderVendorGroupDto
{
VendorId = vendorGrp.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = vendorGrp.Sum(l => l.LbsToOrder),
TotalEstCost = vendorGrp.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = mergedLines
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
@@ -528,11 +528,25 @@
<tbody>
@foreach (var line in vendorGroup.Lines)
{
<tr id="powder-line-@line.CoatId">
<tr id="powder-line-@line.CoatIds[0]">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.JobId"
class="fw-medium text-decoration-none">@line.CustomerName</a>
<span class="text-muted ms-1">(@line.JobNumber)</span>
@if (line.Jobs.Count == 1)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.Jobs[0].JobId"
class="fw-medium text-decoration-none">@line.Jobs[0].CustomerName</a>
<span class="text-muted ms-1">(@line.Jobs[0].JobNumber)</span>
}
else
{
@foreach (var jobRef in line.Jobs)
{
<div>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@jobRef.JobId"
class="fw-medium text-decoration-none">@jobRef.CustomerName</a>
<span class="text-muted ms-1">(@jobRef.JobNumber &middot; @jobRef.LbsToOrder.ToString("N2") lbs)</span>
</div>
}
}
</td>
<td>
@if (!string.IsNullOrEmpty(line.ColorName))
@@ -551,7 +565,7 @@
</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-danger mark-ordered-btn text-nowrap"
data-coat-id="@line.CoatId"
data-coat-ids="@string.Join(",", line.CoatIds)"
title="Mark as ordered">
<i class="bi bi-check2-circle me-1"></i>Mark as Ordered
</button>
@@ -875,10 +889,11 @@
// Powder Orders - Mark as Ordered
document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const coatId = this.dataset.coatId;
const row = document.getElementById('powder-line-' + coatId);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
const coatIds = this.dataset.coatIds;
const firstId = coatIds.split(',')[0];
const row = document.getElementById('powder-line-' + firstId);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
@@ -887,7 +902,7 @@
const resp = await fetch('@Url.Action("MarkPowderOrdered", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: 'coatId=' + coatId
body: 'coatIds=' + encodeURIComponent(coatIds)
});
const data = await resp.json();
if (data.success) {
@@ -905,8 +920,8 @@
badge.textContent = n;
}
}
// Inject into Awaiting Receipt widget
addToAwaitingReceipt(data.coat);
// Inject each marked coat into the Awaiting Receipt widget
(data.coats || []).forEach(c => addToAwaitingReceipt(c));
}, 400);
} else {
alert(data.message || 'Could not update. Please try again.');