From 59f86c8e7955f643fc6c91bf3de09c3e381712e9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 9 Feb 2026 22:12:57 -0500 Subject: [PATCH] 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 --- CutList.Web/Components/Pages/Jobs/Edit.razor | 424 +++++++++++++++++- CutList.Web/Components/Pages/Jobs/Index.razor | 1 - .../Components/Pages/Jobs/Results.razor | 344 -------------- 3 files changed, 406 insertions(+), 363 deletions(-) delete mode 100644 CutList.Web/Components/Pages/Jobs/Results.razor diff --git a/CutList.Web/Components/Pages/Jobs/Edit.razor b/CutList.Web/Components/Pages/Jobs/Edit.razor index ae055c8..806b99f 100644 --- a/CutList.Web/Components/Pages/Jobs/Edit.razor +++ b/CutList.Web/Components/Pages/Jobs/Edit.razor @@ -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 @@

@(IsNew ? "New Job" : job.DisplayName)

- @if (!IsNew) - { - Run Optimization - } + Back to Jobs
@if (!IsNew && job.IsLocked) @@ -73,6 +75,16 @@ else } +
@@ -92,6 +104,10 @@ else { @RenderStockTab() } + else if (activeTab == Tab.Results) + { + @RenderResultsTab() + }
} @@ -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 importCandidates = new(); private string? importErrorMessage; + // Results tab + private MultiMaterialPackResult? packResult; + private MultiMaterialPackingSummary? summary; + private bool optimizing; + private bool addingToOrderList; + private bool addedToOrderList; + private IEnumerable DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s); private IEnumerable FilteredMaterials => !selectedShape.HasValue ? Enumerable.Empty() : 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 => {
@@ -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 -
- - -
+
} @@ -777,16 +832,341 @@ else }; - 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) + { +
+
Cannot Optimize
+
    + @if (job.Parts.Count == 0) + { +
  • No parts defined. Switch to the to add parts.
  • + } + @if (job.CuttingToolId == null) + { +
  • No cutting tool selected. Switch to the to select a cutting tool.
  • + } +
+
+ } + else + { +
+ + @if (packResult != null) + { + + } + @if (job.OptimizedAt.HasValue) + { + + Last optimized: @job.OptimizedAt.Value.ToLocalTime().ToString("g") + + } +
+ + @if (packResult != null && summary != null) + { + @if (summary.TotalItemsNotPlaced > 0) + { +
+
Items Not Placed
+

Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.

+
+ } + + + + + + + + + @foreach (var materialResult in packResult.MaterialResults) + { + var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id); + +
+
+

@materialResult.Material.DisplayName

+
+
+ +
+
+ @(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars +
+
+ @materialSummary.TotalPieces pieces +
+
+ @materialSummary.Efficiency.ToString("F1")% efficiency +
+
+ @materialSummary.InStockBins in stock +
+
+ @materialSummary.ToBePurchasedBins to purchase +
+
+ + @if (materialResult.PackResult.ItemsNotUsed.Count > 0) + { +
+ @materialResult.PackResult.ItemsNotUsed.Count items not placed - + No stock lengths available or parts too long. +
+ } + + @if (materialResult.InStockBins.Count > 0) + { +
In Stock (@materialResult.InStockBins.Count bars)
+ @RenderBinList(materialResult.InStockBins) + } + + @if (materialResult.ToBePurchasedBins.Count > 0) + { +
To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)
+ @RenderBinList(materialResult.ToBePurchasedBins) + + +
+ Order Summary: +
    + @foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key)) + { +
  • @group.Count() x @ArchUnits.FormatFromInches(group.Key)
  • + } +
+
+ } +
+
+ } + } + else if (!optimizing) + { +
+ +

Click Optimize to calculate the most efficient cut list.

+
+ } + } + }; + + private RenderFragment RenderBinList(List bins) => __builder => + { +
+ + + + + + + + + + + @{ var binNumber = 1; } + @foreach (var bin in bins) + { + + + + + + + binNumber++; + } + +
#Stock LengthCutsWaste
@binNumber@ArchUnits.FormatFromInches(bin.Length) + @foreach (var item in bin.Items) + { + + @(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") + + } + @ArchUnits.FormatFromInches(bin.RemainingLength)
+
+ }; + + 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(); + 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) { diff --git a/CutList.Web/Components/Pages/Jobs/Index.razor b/CutList.Web/Components/Pages/Jobs/Index.razor index b44ca70..b7bbad8 100644 --- a/CutList.Web/Components/Pages/Jobs/Index.razor +++ b/CutList.Web/Components/Pages/Jobs/Index.razor @@ -63,7 +63,6 @@ else @((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g")) - diff --git a/CutList.Web/Components/Pages/Jobs/Results.razor b/CutList.Web/Components/Pages/Jobs/Results.razor deleted file mode 100644 index 41f6a43..0000000 --- a/CutList.Web/Components/Pages/Jobs/Results.razor +++ /dev/null @@ -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 - -Results - @(job?.DisplayName ?? "Job") - -@if (loading) -{ -

Loading...

-} -else if (job == null) -{ -
Job not found.
-} -else -{ -
-
-

- @job.DisplayName - @if (job.IsLocked) - { - - } -

- @if (!string.IsNullOrWhiteSpace(job.Customer)) - { -

Customer: @job.Customer

- } -
-
- Edit Job - -
-
- - @if (!CanOptimize) - { -
-

Cannot Optimize

- -
- } - else if (packResult != null) - { - @if (summary!.TotalItemsNotPlaced > 0) - { -
-
Items Not Placed
-

Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.

-
- } - - - - - - - - - @foreach (var materialResult in packResult.MaterialResults) - { - var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id); - -
-
-

@materialResult.Material.DisplayName

-
-
- -
-
- @(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars -
-
- @materialSummary.TotalPieces pieces -
-
- @materialSummary.Efficiency.ToString("F1")% efficiency -
-
- @materialSummary.InStockBins in stock -
-
- @materialSummary.ToBePurchasedBins to purchase -
-
- - @if (materialResult.PackResult.ItemsNotUsed.Count > 0) - { -
- @materialResult.PackResult.ItemsNotUsed.Count items not placed - - No stock lengths available or parts too long. -
- } - - @if (materialResult.InStockBins.Count > 0) - { -
In Stock (@materialResult.InStockBins.Count bars)
- @RenderBinList(materialResult.InStockBins) - } - - @if (materialResult.ToBePurchasedBins.Count > 0) - { -
To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)
- @RenderBinList(materialResult.ToBePurchasedBins) - - -
- Order Summary: -
    - @foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key)) - { -
  • @group.Count() x @ArchUnits.FormatFromInches(group.Key)
  • - } -
-
- } -
-
- } - } -} - -@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 bins) => __builder => - { -
- - - - - - - - - - - @{ var binNumber = 1; } - @foreach (var bin in bins) - { - - - - - - - binNumber++; - } - -
#Stock LengthCutsWaste
@binNumber@ArchUnits.FormatFromInches(bin.Length) - @foreach (var item in bin.Items) - { - - @(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") - - } - @ArchUnits.FormatFromInches(bin.RemainingLength)
-
- }; - - private async Task AddToOrderList() - { - addingToOrderList = true; - try - { - var purchaseItems = new List(); - 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); - } -}