Files
CutList/CutList.Web/Components/Pages/Jobs/Results.razor
AJ Isaacs 2586f99c63 feat: Add purchase order flow with Orders pages
Add "Add to Order List" button on Results page that creates PurchaseItems
from optimization results and locks the job. Add Orders Index page with
tabbed view (Pending/Ordered/All), supplier assignment, status
transitions, and MaterialFilter. Add manual order item creation page.
Add Orders link to navigation menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:03:17 -05:00

345 lines
13 KiB
Plaintext

@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);
}
}