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 MaterialService MaterialService
|
||||
@inject StockItemService StockItemService
|
||||
@inject CutListPackingService PackingService
|
||||
@inject PurchaseItemService PurchaseItemService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@using CutList.Core
|
||||
@using CutList.Core.Nesting
|
||||
@using CutList.Core.Formatting
|
||||
@using CutList.Web.Data.Entities
|
||||
|
||||
@@ -11,10 +16,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||
@if (!IsNew)
|
||||
{
|
||||
<a href="jobs/@Id/results" class="btn btn-success">Run Optimization</a>
|
||||
}
|
||||
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
||||
</div>
|
||||
|
||||
@if (!IsNew && job.IsLocked)
|
||||
@@ -73,6 +75,16 @@ else
|
||||
}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<div class="tab-content">
|
||||
@@ -92,6 +104,10 @@ else
|
||||
{
|
||||
@RenderStockTab()
|
||||
}
|
||||
else if (activeTab == Tab.Results)
|
||||
{
|
||||
@RenderResultsTab()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -253,7 +269,7 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Tab { Details, Parts, Stock }
|
||||
private enum Tab { Details, Parts, Stock, Results }
|
||||
|
||||
[Parameter]
|
||||
public int? Id { get; set; }
|
||||
@@ -292,12 +308,20 @@ else
|
||||
private List<ImportStockCandidate> importCandidates = new();
|
||||
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<Material> FilteredMaterials => !selectedShape.HasValue
|
||||
? Enumerable.Empty<Material>()
|
||||
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
|
||||
|
||||
private bool IsNew => !Id.HasValue;
|
||||
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
||||
|
||||
private async Task UnlockJob()
|
||||
{
|
||||
@@ -322,6 +346,12 @@ else
|
||||
return;
|
||||
}
|
||||
job = existing;
|
||||
|
||||
// Load saved optimization results if available
|
||||
if (job.OptimizationResultJson != null)
|
||||
{
|
||||
LoadSavedResults();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -336,6 +366,25 @@ else
|
||||
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 =>
|
||||
{
|
||||
<div class="card">
|
||||
@@ -474,6 +523,10 @@ else
|
||||
else
|
||||
{
|
||||
await JobService.UpdateAsync(job);
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
// Clear in-memory results since they were invalidated
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -561,12 +614,17 @@ else
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showPartForm = false;
|
||||
editingPart = null;
|
||||
// Results were cleared by the service
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
|
||||
private async Task DeletePart(JobPart part)
|
||||
{
|
||||
await JobService.DeletePartAsync(part.Id);
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
|
||||
// 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")">
|
||||
Import from Inventory
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -777,16 +832,341 @@ else
|
||||
</div>
|
||||
};
|
||||
|
||||
private void ShowAddStockFromInventory()
|
||||
// Results tab
|
||||
private RenderFragment RenderResultsTab() => __builder =>
|
||||
{
|
||||
editingStock = null;
|
||||
newStock = new JobStock { JobId = Id!.Value, Quantity = 1, Priority = 10 };
|
||||
stockSelectedShape = null;
|
||||
stockSelectedMaterialId = 0;
|
||||
availableStockItems.Clear();
|
||||
showStockForm = true;
|
||||
showCustomStockForm = false;
|
||||
stockErrorMessage = null;
|
||||
@if (!CanOptimize)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<h5 class="mb-2">Cannot Optimize</h5>
|
||||
<ul class="mb-0">
|
||||
@if (job.Parts.Count == 0)
|
||||
{
|
||||
<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()
|
||||
@@ -911,6 +1291,8 @@ else
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockForm = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
|
||||
private async Task SaveCustomStockAsync()
|
||||
@@ -956,12 +1338,16 @@ else
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showCustomStockForm = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
|
||||
private async Task DeleteStock(JobStock stock)
|
||||
{
|
||||
await JobService.DeleteStockAsync(stock.Id);
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
|
||||
// Import modal methods
|
||||
@@ -1048,6 +1434,8 @@ else
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showImportModal = false;
|
||||
importCandidates.Clear();
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -63,7 +63,6 @@ else
|
||||
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</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/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-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</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