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:
@@ -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 (>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 · @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.');
|
||||
|
||||
Reference in New Issue
Block a user