refactor: Merge Results page into Job Edit as a tab
Move optimization results UI from separate Results.razor page into the Edit.razor tabbed editor. Results are now loaded from saved JSON on page load instead of re-running on every visit. Remove the standalone optimize button from Jobs index. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,12 @@
|
|||||||
@inject JobService JobService
|
@inject JobService JobService
|
||||||
@inject MaterialService MaterialService
|
@inject MaterialService MaterialService
|
||||||
@inject StockItemService StockItemService
|
@inject StockItemService StockItemService
|
||||||
|
@inject CutListPackingService PackingService
|
||||||
|
@inject PurchaseItemService PurchaseItemService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@using CutList.Core
|
||||||
|
@using CutList.Core.Nesting
|
||||||
@using CutList.Core.Formatting
|
@using CutList.Core.Formatting
|
||||||
@using CutList.Web.Data.Entities
|
@using CutList.Web.Data.Entities
|
||||||
|
|
||||||
@@ -11,10 +16,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||||
@if (!IsNew)
|
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
||||||
{
|
|
||||||
<a href="jobs/@Id/results" class="btn btn-success">Run Optimization</a>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!IsNew && job.IsLocked)
|
@if (!IsNew && job.IsLocked)
|
||||||
@@ -73,6 +75,16 @@ else
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link @(activeTab == Tab.Results ? "active" : "")"
|
||||||
|
@onclick="() => SetTab(Tab.Results)" type="button">
|
||||||
|
Results
|
||||||
|
@if (summary != null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success ms-1">@summary.Efficiency.ToString("F0")%</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -92,6 +104,10 @@ else
|
|||||||
{
|
{
|
||||||
@RenderStockTab()
|
@RenderStockTab()
|
||||||
}
|
}
|
||||||
|
else if (activeTab == Tab.Results)
|
||||||
|
{
|
||||||
|
@RenderResultsTab()
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +269,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private enum Tab { Details, Parts, Stock }
|
private enum Tab { Details, Parts, Stock, Results }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public int? Id { get; set; }
|
public int? Id { get; set; }
|
||||||
@@ -292,12 +308,20 @@ else
|
|||||||
private List<ImportStockCandidate> importCandidates = new();
|
private List<ImportStockCandidate> importCandidates = new();
|
||||||
private string? importErrorMessage;
|
private string? importErrorMessage;
|
||||||
|
|
||||||
|
// Results tab
|
||||||
|
private MultiMaterialPackResult? packResult;
|
||||||
|
private MultiMaterialPackingSummary? summary;
|
||||||
|
private bool optimizing;
|
||||||
|
private bool addingToOrderList;
|
||||||
|
private bool addedToOrderList;
|
||||||
|
|
||||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||||
? Enumerable.Empty<Material>()
|
? Enumerable.Empty<Material>()
|
||||||
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
|
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
|
||||||
|
|
||||||
private bool IsNew => !Id.HasValue;
|
private bool IsNew => !Id.HasValue;
|
||||||
|
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
||||||
|
|
||||||
private async Task UnlockJob()
|
private async Task UnlockJob()
|
||||||
{
|
{
|
||||||
@@ -322,6 +346,12 @@ else
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job = existing;
|
job = existing;
|
||||||
|
|
||||||
|
// Load saved optimization results if available
|
||||||
|
if (job.OptimizationResultJson != null)
|
||||||
|
{
|
||||||
|
LoadSavedResults();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -336,6 +366,25 @@ else
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadSavedResults()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
packResult = PackingService.LoadSavedResult(job.OptimizationResultJson!);
|
||||||
|
if (packResult != null)
|
||||||
|
{
|
||||||
|
summary = PackingService.GetSummary(packResult);
|
||||||
|
addedToOrderList = job.IsLocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Invalid JSON — treat as no results
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private RenderFragment RenderDetailsForm() => __builder =>
|
private RenderFragment RenderDetailsForm() => __builder =>
|
||||||
{
|
{
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -474,6 +523,10 @@ else
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
await JobService.UpdateAsync(job);
|
await JobService.UpdateAsync(job);
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
// Clear in-memory results since they were invalidated
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -561,12 +614,17 @@ else
|
|||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showPartForm = false;
|
showPartForm = false;
|
||||||
editingPart = null;
|
editingPart = null;
|
||||||
|
// Results were cleared by the service
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeletePart(JobPart part)
|
private async Task DeletePart(JobPart part)
|
||||||
{
|
{
|
||||||
await JobService.DeletePartAsync(part.Id);
|
await JobService.DeletePartAsync(part.Id);
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock tab
|
// Stock tab
|
||||||
@@ -582,10 +640,7 @@ else
|
|||||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
||||||
Import from Inventory
|
Import from Inventory
|
||||||
</button>
|
</button>
|
||||||
<div class="btn-group">
|
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
||||||
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
|
|
||||||
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -777,16 +832,341 @@ else
|
|||||||
</div>
|
</div>
|
||||||
};
|
};
|
||||||
|
|
||||||
private void ShowAddStockFromInventory()
|
// Results tab
|
||||||
|
private RenderFragment RenderResultsTab() => __builder =>
|
||||||
{
|
{
|
||||||
editingStock = null;
|
@if (!CanOptimize)
|
||||||
newStock = new JobStock { JobId = Id!.Value, Quantity = 1, Priority = 10 };
|
{
|
||||||
stockSelectedShape = null;
|
<div class="alert alert-warning">
|
||||||
stockSelectedMaterialId = 0;
|
<h5 class="mb-2">Cannot Optimize</h5>
|
||||||
availableStockItems.Clear();
|
<ul class="mb-0">
|
||||||
showStockForm = true;
|
@if (job.Parts.Count == 0)
|
||||||
showCustomStockForm = false;
|
{
|
||||||
stockErrorMessage = null;
|
<li>No parts defined. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Parts)">Parts tab</button> to add parts.</li>
|
||||||
|
}
|
||||||
|
@if (job.CuttingToolId == null)
|
||||||
|
{
|
||||||
|
<li>No cutting tool selected. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Details)">Details tab</button> to select a cutting tool.</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<button class="btn btn-success" @onclick="RunOptimization" disabled="@(optimizing || job.IsLocked)">
|
||||||
|
@if (optimizing)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
<text>Optimizing...</text>
|
||||||
|
}
|
||||||
|
else if (packResult != null)
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||||
|
<text>Re-Optimize</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-scissors me-1"></i>
|
||||||
|
<text>Optimize</text>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (packResult != null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
||||||
|
<i class="bi bi-printer me-1"></i> Print Report
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (job.OptimizedAt.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-3">
|
||||||
|
Last optimized: @job.OptimizedAt.Value.ToLocalTime().ToString("g")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (packResult != null && summary != null)
|
||||||
|
{
|
||||||
|
@if (summary.TotalItemsNotPlaced > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h5>Items Not Placed</h5>
|
||||||
|
<p class="mb-0">Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Overall Summary Cards -->
|
||||||
|
<div class="row mb-4 print-summary">
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-0">@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)</h2>
|
||||||
|
<p class="card-text text-muted">Total Stock Bars</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
|
||||||
|
<p class="card-text text-muted">Total Pieces</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
||||||
|
<p class="card-text text-muted">Total Waste</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
|
||||||
|
<p class="card-text text-muted">Efficiency</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock Summary -->
|
||||||
|
<div class="row mb-4 print-stock-summary">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h5 class="mb-0">In Stock</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>@summary.TotalInStockBins bars</h3>
|
||||||
|
<p class="text-muted mb-0">Ready to cut from existing inventory</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-header bg-warning">
|
||||||
|
<h5 class="mb-0">To Be Purchased</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>@summary.TotalToBePurchasedBins bars</h3>
|
||||||
|
@if (summary.TotalToBePurchasedBins > 0)
|
||||||
|
{
|
||||||
|
@if (addedToOrderList)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mb-0 mt-2 py-2">
|
||||||
|
Added to order list. <a href="orders">View Orders</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-warning btn-sm mt-2" @onclick="AddToOrderList" disabled="@addingToOrderList">
|
||||||
|
@if (addingToOrderList)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<i class="bi bi-cart-plus"></i> Add to Order List
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">Everything available in stock</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results by Material -->
|
||||||
|
@foreach (var materialResult in packResult.MaterialResults)
|
||||||
|
{
|
||||||
|
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0">@materialResult.Material.DisplayName</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Material Summary -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-2 col-4">
|
||||||
|
<strong>@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins)</strong> bars
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-4">
|
||||||
|
<strong>@materialSummary.TotalPieces</strong> pieces
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-4">
|
||||||
|
<strong>@materialSummary.Efficiency.ToString("F1")%</strong> efficiency
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<span class="text-success">@materialSummary.InStockBins in stock</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-6">
|
||||||
|
<span class="text-warning">@materialSummary.ToBePurchasedBins to purchase</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (materialResult.PackResult.ItemsNotUsed.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> -
|
||||||
|
No stock lengths available or parts too long.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (materialResult.InStockBins.Count > 0)
|
||||||
|
{
|
||||||
|
<h5 class="text-success mt-3">In Stock (@materialResult.InStockBins.Count bars)</h5>
|
||||||
|
@RenderBinList(materialResult.InStockBins)
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (materialResult.ToBePurchasedBins.Count > 0)
|
||||||
|
{
|
||||||
|
<h5 class="text-warning mt-3">To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)</h5>
|
||||||
|
@RenderBinList(materialResult.ToBePurchasedBins)
|
||||||
|
|
||||||
|
<!-- Purchase Summary -->
|
||||||
|
<div class="mt-3 p-3 bg-light rounded">
|
||||||
|
<strong>Order Summary:</strong>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
|
||||||
|
{
|
||||||
|
<li>@group.Count() x @ArchUnits.FormatFromInches(group.Key)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!optimizing)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-scissors display-4"></i>
|
||||||
|
<p class="mt-3">Click <strong>Optimize</strong> to calculate the most efficient cut list.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderBinList(List<Bin> bins) => __builder =>
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 80px;">#</th>
|
||||||
|
<th>Stock Length</th>
|
||||||
|
<th>Cuts</th>
|
||||||
|
<th>Waste</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{ var binNumber = 1; }
|
||||||
|
@foreach (var bin in bins)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@binNumber</td>
|
||||||
|
<td>@ArchUnits.FormatFromInches(bin.Length)</td>
|
||||||
|
<td>
|
||||||
|
@foreach (var item in bin.Items)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary me-1">
|
||||||
|
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@ArchUnits.FormatFromInches(bin.RemainingLength)</td>
|
||||||
|
</tr>
|
||||||
|
binNumber++;
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task RunOptimization()
|
||||||
|
{
|
||||||
|
optimizing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var kerf = job.CuttingTool?.KerfInches ?? 0.125m;
|
||||||
|
packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null);
|
||||||
|
summary = PackingService.GetSummary(packResult);
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
var json = PackingService.SerializeResult(packResult);
|
||||||
|
await JobService.SaveOptimizationResultAsync(Id!.Value, json, DateTime.UtcNow);
|
||||||
|
|
||||||
|
// Refresh job to get updated OptimizedAt
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
addedToOrderList = job.IsLocked;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
optimizing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddToOrderList()
|
||||||
|
{
|
||||||
|
addingToOrderList = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var purchaseItems = new List<PurchaseItem>();
|
||||||
|
var stockItems = await StockItemService.GetAllAsync();
|
||||||
|
|
||||||
|
foreach (var materialResult in packResult!.MaterialResults)
|
||||||
|
{
|
||||||
|
if (materialResult.ToBePurchasedBins.Count == 0) continue;
|
||||||
|
|
||||||
|
var materialId = materialResult.Material.Id;
|
||||||
|
|
||||||
|
// Group bins by length to consolidate quantities
|
||||||
|
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
|
||||||
|
{
|
||||||
|
var lengthInches = (decimal)group.Key;
|
||||||
|
var quantity = group.Count();
|
||||||
|
|
||||||
|
// Find the matching stock item
|
||||||
|
var stockItem = stockItems.FirstOrDefault(s =>
|
||||||
|
s.MaterialId == materialId && s.LengthInches == lengthInches);
|
||||||
|
|
||||||
|
if (stockItem != null)
|
||||||
|
{
|
||||||
|
purchaseItems.Add(new PurchaseItem
|
||||||
|
{
|
||||||
|
StockItemId = stockItem.Id,
|
||||||
|
Quantity = quantity,
|
||||||
|
JobId = Id!.Value,
|
||||||
|
Status = PurchaseItemStatus.Pending
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchaseItems.Count > 0)
|
||||||
|
{
|
||||||
|
await PurchaseItemService.CreateBulkAsync(purchaseItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
await JobService.LockAsync(Id!.Value);
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
addedToOrderList = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
addingToOrderList = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrintReport()
|
||||||
|
{
|
||||||
|
var filename = $"CutList - {job.Name} - {DateTime.Now:yyyy-MM-dd}";
|
||||||
|
await JS.InvokeVoidAsync("printWithTitle", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowAddCustomStock()
|
private void ShowAddCustomStock()
|
||||||
@@ -911,6 +1291,8 @@ else
|
|||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showStockForm = false;
|
showStockForm = false;
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveCustomStockAsync()
|
private async Task SaveCustomStockAsync()
|
||||||
@@ -956,12 +1338,16 @@ else
|
|||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showCustomStockForm = false;
|
showCustomStockForm = false;
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteStock(JobStock stock)
|
private async Task DeleteStock(JobStock stock)
|
||||||
{
|
{
|
||||||
await JobService.DeleteStockAsync(stock.Id);
|
await JobService.DeleteStockAsync(stock.Id);
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import modal methods
|
// Import modal methods
|
||||||
@@ -1048,6 +1434,8 @@ else
|
|||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showImportModal = false;
|
showImportModal = false;
|
||||||
importCandidates.Clear();
|
importCandidates.Clear();
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ else
|
|||||||
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<a href="jobs/@job.Id/results" class="btn btn-sm btn-success" title="Optimize"><i class="bi bi-scissors"></i></a>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
@page "/jobs/{Id:int}/results"
|
|
||||||
@inject JobService JobService
|
|
||||||
@inject CutListPackingService PackingService
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject PurchaseItemService PurchaseItemService
|
|
||||||
@inject StockItemService StockItemService
|
|
||||||
@using CutList.Core
|
|
||||||
@using CutList.Core.Nesting
|
|
||||||
@using CutList.Core.Formatting
|
|
||||||
|
|
||||||
<PageTitle>Results - @(job?.DisplayName ?? "Job")</PageTitle>
|
|
||||||
|
|
||||||
@if (loading)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else if (job == null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">Job not found.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<div>
|
|
||||||
<h1>
|
|
||||||
@job.DisplayName
|
|
||||||
@if (job.IsLocked)
|
|
||||||
{
|
|
||||||
<i class="bi bi-lock-fill text-warning ms-2" title="Job locked — materials ordered"></i>
|
|
||||||
}
|
|
||||||
</h1>
|
|
||||||
@if (!string.IsNullOrWhiteSpace(job.Customer))
|
|
||||||
{
|
|
||||||
<p class="text-muted mb-0">Customer: @job.Customer</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="jobs/@Id" class="btn btn-outline-secondary me-2">Edit Job</a>
|
|
||||||
<button class="btn btn-primary" @onclick="PrintReport">Print Report</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!CanOptimize)
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<h4>Cannot Optimize</h4>
|
|
||||||
<ul class="mb-0">
|
|
||||||
@if (job.Parts.Count == 0)
|
|
||||||
{
|
|
||||||
<li>No parts defined. <a href="jobs/@Id">Add parts to the job</a>.</li>
|
|
||||||
}
|
|
||||||
@if (job.CuttingToolId == null)
|
|
||||||
{
|
|
||||||
<li>No cutting tool selected. <a href="jobs/@Id">Select a cutting tool</a>.</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (packResult != null)
|
|
||||||
{
|
|
||||||
@if (summary!.TotalItemsNotPlaced > 0)
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<h5>Items Not Placed</h5>
|
|
||||||
<p>Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Overall Summary Cards -->
|
|
||||||
<div class="row mb-4 print-summary">
|
|
||||||
<div class="col-md-3 col-6 mb-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title mb-0">@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)</h2>
|
|
||||||
<p class="card-text text-muted">Total Stock Bars</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
|
|
||||||
<p class="card-text text-muted">Total Pieces</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
|
||||||
<p class="card-text text-muted">Total Waste</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-3">
|
|
||||||
<div class="card text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
|
|
||||||
<p class="card-text text-muted">Efficiency</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stock Summary -->
|
|
||||||
<div class="row mb-4 print-stock-summary">
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="card border-success">
|
|
||||||
<div class="card-header bg-success text-white">
|
|
||||||
<h5 class="mb-0">In Stock</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3>@summary.TotalInStockBins bars</h3>
|
|
||||||
<p class="text-muted mb-0">Ready to cut from existing inventory</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="card border-warning">
|
|
||||||
<div class="card-header bg-warning">
|
|
||||||
<h5 class="mb-0">To Be Purchased</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<h3>@summary.TotalToBePurchasedBins bars</h3>
|
|
||||||
@if (summary.TotalToBePurchasedBins > 0)
|
|
||||||
{
|
|
||||||
@if (addedToOrderList)
|
|
||||||
{
|
|
||||||
<div class="alert alert-success mb-0 mt-2 py-2">
|
|
||||||
Added to order list. <a href="orders">View Orders</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-warning btn-sm mt-2" @onclick="AddToOrderList" disabled="@addingToOrderList">
|
|
||||||
@if (addingToOrderList)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
||||||
}
|
|
||||||
<i class="bi bi-cart-plus"></i> Add to Order List
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-muted mb-0">Need to order from supplier</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results by Material -->
|
|
||||||
@foreach (var materialResult in packResult.MaterialResults)
|
|
||||||
{
|
|
||||||
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h4 class="mb-0">@materialResult.Material.DisplayName</h4>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<!-- Material Summary -->
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-2 col-4">
|
|
||||||
<strong>@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins)</strong> bars
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 col-4">
|
|
||||||
<strong>@materialSummary.TotalPieces</strong> pieces
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 col-4">
|
|
||||||
<strong>@materialSummary.Efficiency.ToString("F1")%</strong> efficiency
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<span class="text-success">@materialSummary.InStockBins in stock</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<span class="text-warning">@materialSummary.ToBePurchasedBins to purchase</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (materialResult.PackResult.ItemsNotUsed.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> -
|
|
||||||
No stock lengths available or parts too long.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (materialResult.InStockBins.Count > 0)
|
|
||||||
{
|
|
||||||
<h5 class="text-success mt-3">In Stock (@materialResult.InStockBins.Count bars)</h5>
|
|
||||||
@RenderBinList(materialResult.InStockBins)
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (materialResult.ToBePurchasedBins.Count > 0)
|
|
||||||
{
|
|
||||||
<h5 class="text-warning mt-3">To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)</h5>
|
|
||||||
@RenderBinList(materialResult.ToBePurchasedBins)
|
|
||||||
|
|
||||||
<!-- Purchase Summary -->
|
|
||||||
<div class="mt-3 p-3 bg-light rounded">
|
|
||||||
<strong>Order Summary:</strong>
|
|
||||||
<ul class="mb-0 mt-2">
|
|
||||||
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
|
|
||||||
{
|
|
||||||
<li>@group.Count() x @ArchUnits.FormatFromInches(group.Key)</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter]
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
private Job? job;
|
|
||||||
private MultiMaterialPackResult? packResult;
|
|
||||||
private MultiMaterialPackingSummary? summary;
|
|
||||||
private bool loading = true;
|
|
||||||
|
|
||||||
private bool addingToOrderList;
|
|
||||||
private bool addedToOrderList;
|
|
||||||
|
|
||||||
private bool CanOptimize => job != null &&
|
|
||||||
job.Parts.Count > 0 &&
|
|
||||||
job.CuttingToolId != null;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
job = await JobService.GetByIdAsync(Id);
|
|
||||||
|
|
||||||
if (job != null && CanOptimize)
|
|
||||||
{
|
|
||||||
var kerf = job.CuttingTool?.KerfInches ?? 0.125m;
|
|
||||||
// Pass job stock if configured, otherwise packing service uses all available stock
|
|
||||||
packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null);
|
|
||||||
summary = PackingService.GetSummary(packResult);
|
|
||||||
addedToOrderList = job.IsLocked;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderBinList(List<Bin> bins) => __builder =>
|
|
||||||
{
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 80px;">#</th>
|
|
||||||
<th>Stock Length</th>
|
|
||||||
<th>Cuts</th>
|
|
||||||
<th>Waste</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@{ var binNumber = 1; }
|
|
||||||
@foreach (var bin in bins)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@binNumber</td>
|
|
||||||
<td>@ArchUnits.FormatFromInches(bin.Length)</td>
|
|
||||||
<td>
|
|
||||||
@foreach (var item in bin.Items)
|
|
||||||
{
|
|
||||||
<span class="badge bg-primary me-1">
|
|
||||||
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>@ArchUnits.FormatFromInches(bin.RemainingLength)</td>
|
|
||||||
</tr>
|
|
||||||
binNumber++;
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task AddToOrderList()
|
|
||||||
{
|
|
||||||
addingToOrderList = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var purchaseItems = new List<PurchaseItem>();
|
|
||||||
var stockItems = await StockItemService.GetAllAsync();
|
|
||||||
|
|
||||||
foreach (var materialResult in packResult!.MaterialResults)
|
|
||||||
{
|
|
||||||
if (materialResult.ToBePurchasedBins.Count == 0) continue;
|
|
||||||
|
|
||||||
var materialId = materialResult.Material.Id;
|
|
||||||
|
|
||||||
// Group bins by length to consolidate quantities
|
|
||||||
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
|
|
||||||
{
|
|
||||||
var lengthInches = (decimal)group.Key;
|
|
||||||
var quantity = group.Count();
|
|
||||||
|
|
||||||
// Find the matching stock item
|
|
||||||
var stockItem = stockItems.FirstOrDefault(s =>
|
|
||||||
s.MaterialId == materialId && s.LengthInches == lengthInches);
|
|
||||||
|
|
||||||
if (stockItem != null)
|
|
||||||
{
|
|
||||||
purchaseItems.Add(new PurchaseItem
|
|
||||||
{
|
|
||||||
StockItemId = stockItem.Id,
|
|
||||||
Quantity = quantity,
|
|
||||||
JobId = Id,
|
|
||||||
Status = PurchaseItemStatus.Pending
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purchaseItems.Count > 0)
|
|
||||||
{
|
|
||||||
await PurchaseItemService.CreateBulkAsync(purchaseItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
await JobService.LockAsync(Id);
|
|
||||||
job = await JobService.GetByIdAsync(Id);
|
|
||||||
addedToOrderList = true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
addingToOrderList = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PrintReport()
|
|
||||||
{
|
|
||||||
var filename = $"CutList - {job!.Name} - {DateTime.Now:yyyy-MM-dd}";
|
|
||||||
await JS.InvokeVoidAsync("printWithTitle", filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user