Files
CutList/CutList.Web/Components/Pages/Jobs/Edit.razor
T
aj 8e2027d81b
Build CutList image / build-and-push (push) Successful in 21s
feat(web): split cut badge lengths
2026-08-03 00:09:44 +00:00

1529 lines
64 KiB
Plaintext

@page "/jobs/new"
@page "/jobs/{Id:int}"
@inject JobService JobService
@inject MaterialService MaterialService
@inject CutListPackingService PackingService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using CutList.Core
@using CutList.Core.Nesting
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
</div>
@if (!IsNew && job.IsLocked)
{
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-lock-fill me-2"></i>
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
</div>
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
<i class="bi bi-unlock"></i> Unlock Job
</button>
</div>
}
@if (!string.IsNullOrEmpty(loadError))
{
<div class="alert alert-danger">
<h5>Error loading job</h5>
<pre class="mb-0">@loadError</pre>
</div>
}
else if (loading)
{
<p><em>Loading...</em></p>
}
else if (IsNew)
{
<!-- New Job: Simple form -->
<div class="row">
<div class="col-lg-6">
@RenderDetailsForm()
</div>
</div>
}
else
{
<!-- Existing Job: Tabbed interface -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Details ? "active" : "")"
@onclick="() => SetTab(Tab.Details)" type="button">
Details
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Parts ? "active" : "")"
@onclick="() => SetTab(Tab.Parts)" type="button">
Parts
@if (job.Parts.Count > 0)
{
<span class="badge bg-secondary ms-1">@job.Parts.Sum(p => p.Quantity)</span>
}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Stock ? "active" : "")"
@onclick="() => SetTab(Tab.Stock)" type="button">
Stock
@if (job.Stock.Count > 0)
{
<span class="badge bg-secondary ms-1">@job.Stock.Count</span>
}
</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">
@if (activeTab == Tab.Details)
{
<div class="row">
<div class="col-lg-6">
@RenderDetailsForm()
</div>
</div>
}
else if (activeTab == Tab.Parts)
{
@RenderPartsTab()
}
else if (activeTab == Tab.Stock)
{
@RenderStockTab()
}
else if (activeTab == Tab.Results)
{
@RenderResultsTab()
}
</div>
}
@* Part Modal Dialog *@
@if (showPartForm)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingPart == null ? "Add Parts" : "Edit Part")</h5>
<button type="button" class="btn-close" @onclick="CancelPartForm"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Shape</label>
<select class="form-select" @bind="selectedShape" @bind:after="OnShapeChanged"
disabled="@(editingPart != null)">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Size</label>
<select class="form-select" @bind="partSelectedMaterialId" disabled="@(!selectedShape.HasValue || editingPart != null)">
<option value="0">-- Select --</option>
@foreach (var material in FilteredMaterials)
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
</div>
@if (editingPart != null)
{
@* Edit mode: single row *@
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newPart.LengthInches" />
</div>
<div class="col-md-4">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newPart.Quantity" min="1" />
</div>
<div class="col-md-4">
<label class="form-label">Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" @bind="newPart.Name" placeholder="Part name" />
</div>
</div>
}
else
{
@* Add mode: multi-row table *@
<table class="table table-sm align-middle mb-2">
<thead>
<tr>
<th>Length</th>
<th style="width: 100px;">Qty</th>
<th>Name <span class="text-muted fw-normal">(optional)</span></th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
@for (var i = 0; i < partRows.Count; i++)
{
var row = partRows[i];
<tr>
<td><LengthInput @bind-Value="row.LengthInches" /></td>
<td><input type="number" class="form-control form-control-sm" @bind="row.Quantity" @bind:event="oninput" min="1" /></td>
<td><input type="text" class="form-control form-control-sm" @bind="row.Name" @bind:event="oninput" placeholder="Part name" /></td>
<td>
@if (partRows.Count > 1)
{
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="() => RemovePartRow(row)" title="Remove">
<i class="bi bi-x-lg"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="AddPartRow">
<i class="bi bi-plus-lg me-1"></i>Add Row
</button>
}
@if (!string.IsNullOrEmpty(partErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@partErrorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelPartForm">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SavePartAsync">
@if (editingPart != null)
{
<text>Save Changes</text>
}
else
{
<text>Add @partRows.Count Part@(partRows.Count != 1 ? "s" : "")</text>
}
</button>
</div>
</div>
</div>
</div>
}
@* Add/Edit Stock Modal *@
@if (showStockModal)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
</div>
<div class="modal-body">
@if (editingStock == null)
{
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
type="button" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
@onclick="() => stockModalTab = StockModalTab.Inventory">
From Inventory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
type="button"
@onclick="() => stockModalTab = StockModalTab.Custom">
Custom Length
</button>
</li>
</ul>
}
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
@if (loadingImport)
{
<div class="text-center py-4">
<span class="spinner-border"></span>
<p class="mt-2 text-muted">Finding matching stock...</p>
</div>
}
else if (importCandidates.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No matching inventory stock found.</p>
<p class="small">Either no stock items exist for the materials in your parts, or they have already been added to this job.</p>
</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(true)">Select All</button>
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(false)">Select None</button>
</div>
<small class="text-muted">@importCandidates.Count(c => c.Selected) of @importCandidates.Count selected</small>
</div>
@foreach (var group in importCandidates
.GroupBy(c => c.StockItem.MaterialId)
.OrderBy(g => g.First().StockItem.Material.Shape)
.ThenBy(g => g.First().StockItem.Material.Size))
{
var material = group.First().StockItem.Material;
<h6 class="mt-3 mb-2 text-primary">@material.DisplayName</h6>
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Length</th>
<th style="width: 120px;">Qty to Use</th>
<th style="width: 100px;">Priority</th>
</tr>
</thead>
<tbody>
@foreach (var candidate in group.OrderByDescending(c => c.StockItem.LengthInches))
{
<tr class="@(candidate.Selected ? "" : "text-muted")">
<td>
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
</td>
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
<td>
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
min="-1" disabled="@(!candidate.Selected)" />
<small class="text-muted">-1 = unlimited</small>
</td>
<td>
<input type="number" class="form-control form-control-sm" @bind="candidate.Priority"
min="1" disabled="@(!candidate.Selected)" />
</td>
</tr>
}
</tbody>
</table>
}
}
@if (!string.IsNullOrEmpty(importErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
}
}
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
{
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stock Length</label>
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
<option value="">-- Select --</option>
@foreach (var stock in availableStockItems)
{
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Qty to Use</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
<small class="text-muted">-1 = unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
}
else
{
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-3">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
<small class="text-muted">Use -1 for unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
@if (importCandidates.Count > 0)
{
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
disabled="@(!importCandidates.Any(c => c.Selected))">
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
</button>
}
}
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
{
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
}
else
{
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
private enum Tab { Details, Parts, Stock, Results }
[Parameter]
public int? Id { get; set; }
private Job job = new();
private List<Material> materials = new();
private List<CuttingTool> cuttingTools = new();
private bool loading = true;
private string? loadError;
private bool savingJob;
private string? jobErrorMessage;
private Tab activeTab = Tab.Details;
private void SetTab(Tab tab) => activeTab = tab;
// Parts form
private bool showPartForm;
private JobPart newPart = new();
private JobPart? editingPart;
private string? partErrorMessage;
private MaterialShape? selectedShape;
private int partSelectedMaterialId;
private List<PartRow> partRows = new();
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
private enum StockModalTab { Inventory, Custom }
private bool showStockModal;
private StockModalTab stockModalTab = StockModalTab.Inventory;
private JobStock newStock = new();
private JobStock? editingStock;
private string? stockErrorMessage;
private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new();
private bool loadingImport;
private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage;
// Results tab
private MultiMaterialPackResult? packResult;
private MultiMaterialPackingSummary? summary;
private bool optimizing;
private bool lockingJob;
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()
{
if (Id.HasValue)
{
await JobService.UnlockAsync(Id.Value);
job = (await JobService.GetByIdAsync(Id.Value))!;
}
}
protected override async Task OnInitializedAsync()
{
try
{
materials = await MaterialService.GetAllAsync();
cuttingTools = await JobService.GetCuttingToolsAsync();
if (Id.HasValue)
{
var existing = await JobService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("jobs");
return;
}
job = existing;
// Load saved optimization results if available
if (job.OptimizationResultJson != null)
{
await LoadSavedResultsAsync();
}
}
else
{
// Set default cutting tool for new jobs
var defaultTool = await JobService.GetDefaultCuttingToolAsync();
if (defaultTool != null)
{
job.CuttingToolId = defaultTool.Id;
}
}
}
catch (Exception ex)
{
loadError = ex.ToString();
}
loading = false;
}
private async Task LoadSavedResultsAsync()
{
try
{
packResult = await PackingService.LoadSavedResultAsync(job.OptimizationResultJson!);
if (packResult != null)
{
summary = PackingService.GetSummary(packResult);
}
}
catch
{
// Invalid JSON — treat as no results
packResult = null;
summary = null;
}
}
private RenderFragment RenderDetailsForm() => __builder =>
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">Job Details</h5>
</div>
<div class="card-body">
<EditForm Model="job" OnValidSubmit="SaveJobAsync">
<fieldset disabled="@job.IsLocked">
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">Job Number</label>
<input type="text" class="form-control" value="@job.JobNumber" readonly />
</div>
}
<div class="mb-3">
<label class="form-label">Job Name <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Name" placeholder="Descriptive name for this job" />
</div>
<div class="mb-3">
<label class="form-label">Customer <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Customer" placeholder="Customer name" />
</div>
<div class="mb-3">
<label class="form-label">Cutting Tool</label>
<InputSelect class="form-select" @bind-Value="job.CuttingToolId">
<option value="">-- Select Tool --</option>
@foreach (var tool in cuttingTools)
{
<option value="@tool.Id">@tool.Name (@tool.KerfInches" kerf)</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="job.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(jobErrorMessage))
{
<div class="alert alert-danger">@jobErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingJob">
@if (savingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Job" : "Save")
</button>
<a href="jobs" class="btn btn-outline-secondary">Back</a>
</div>
</fieldset>
</EditForm>
</div>
</div>
};
private RenderFragment RenderPartsTab() => __builder =>
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Parts to Cut</h5>
@if (!job.IsLocked)
{
<button class="btn btn-primary" @onclick="ShowAddPartForm">Add Part</button>
}
</div>
<div class="card-body">
@if (job.Parts.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No parts added yet.</p>
<p class="small">Add the parts you need to cut, selecting the material for each.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Name</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var part in job.Parts)
{
<tr>
<td>@part.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)part.LengthInches) <span class="text-muted">(@(part.LengthInches)")</span></td>
<td>@part.Quantity</td>
<td>@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name)</td>
<td>
@if (!job.IsLocked)
{
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="mt-3 text-muted">
Total: @job.Parts.Sum(p => p.Quantity) pieces
</div>
}
</div>
</div>
};
private async Task SaveJobAsync()
{
jobErrorMessage = null;
savingJob = true;
try
{
if (IsNew)
{
var created = await JobService.CreateAsync(job);
Navigation.NavigateTo($"jobs/{created.Id}");
}
else
{
await JobService.UpdateAsync(job);
job = (await JobService.GetByIdAsync(Id!.Value))!;
// Clear in-memory results since they were invalidated
packResult = null;
summary = null;
}
}
finally
{
savingJob = false;
}
}
// Parts methods
private void ShowAddPartForm()
{
editingPart = null;
newPart = new JobPart { JobId = Id!.Value, Quantity = 1 };
selectedShape = null;
partSelectedMaterialId = 0;
partRows = new List<PartRow> { new PartRow() };
showPartForm = true;
partErrorMessage = null;
}
private void OnShapeChanged()
{
partSelectedMaterialId = 0;
newPart.MaterialId = 0;
}
private void AddPartRow()
{
partRows.Add(new PartRow());
}
private void RemovePartRow(PartRow row)
{
partRows.Remove(row);
}
private void EditPart(JobPart part)
{
editingPart = part;
newPart = new JobPart
{
Id = part.Id,
JobId = part.JobId,
MaterialId = part.MaterialId,
Name = part.Name,
LengthInches = part.LengthInches,
Quantity = part.Quantity,
SortOrder = part.SortOrder
};
selectedShape = part.Material?.Shape;
partSelectedMaterialId = part.MaterialId;
partRows.Clear();
showPartForm = true;
partErrorMessage = null;
}
private void CancelPartForm()
{
showPartForm = false;
editingPart = null;
}
private async Task SavePartAsync()
{
partErrorMessage = null;
if (!selectedShape.HasValue)
{
partErrorMessage = "Please select a shape";
return;
}
if (partSelectedMaterialId == 0)
{
partErrorMessage = "Please select a size";
return;
}
if (editingPart != null)
{
// Edit mode: single part
if (newPart.LengthInches <= 0)
{
partErrorMessage = "Length must be greater than zero";
return;
}
if (newPart.Quantity < 1)
{
partErrorMessage = "Quantity must be at least 1";
return;
}
newPart.MaterialId = partSelectedMaterialId;
await JobService.UpdatePartAsync(newPart);
}
else
{
// Add mode: multiple rows
for (int i = 0; i < partRows.Count; i++)
{
var row = partRows[i];
if (row.LengthInches <= 0)
{
partErrorMessage = $"Row {i + 1}: Length must be greater than zero";
return;
}
if (row.Quantity < 1)
{
partErrorMessage = $"Row {i + 1}: Quantity must be at least 1";
return;
}
}
foreach (var row in partRows)
{
var part = new JobPart
{
JobId = Id!.Value,
MaterialId = partSelectedMaterialId,
LengthInches = row.LengthInches,
Quantity = row.Quantity,
Name = row.Name ?? string.Empty
};
await JobService.AddPartAsync(part);
}
}
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
private RenderFragment RenderStockTab() => __builder =>
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock for This Job</h5>
@if (!job.IsLocked)
{
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
}
</div>
<div class="card-body">
@if (job.Stock.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No stock configured for this job.</p>
<p class="small">Add stock from your inventory or define custom lengths.</p>
<p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
</div>
}
else
{
@RenderStockTable()
}
</div>
</div>
};
private RenderFragment RenderStockTable() => __builder =>
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Priority</th>
<th>Source</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var stock in job.Stock.OrderBy(s => s.Material?.Shape).ThenBy(s => s.Material?.Size).ThenBy(s => s.Priority))
{
<tr>
<td>@stock.Material?.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches) <span class="text-muted">(@(stock.LengthInches)")</span></td>
<td>@(stock.Quantity == -1 ? "Unlimited" : stock.Quantity.ToString())</td>
<td>@stock.Priority</td>
<td>
@if (stock.IsCustomLength)
{
<span class="badge bg-info">Custom</span>
}
else
{
<span class="badge bg-secondary">Inventory</span>
}
</td>
<td>
@if (!job.IsLocked)
{
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
};
// Results tab
private RenderFragment RenderResultsTab() => __builder =>
{
@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.IsLocked)
{
<button class="btn btn-warning ms-2" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</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>
<!-- Material List: required stock lengths not covered by job stock -->
<div class="card mb-4 print-material-list">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-box-seam me-2"></i>Material List</h5>
@if (job.IsLocked)
{
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">No additional material lengths are required beyond the stock configured for this job.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th class="text-end">Qty</th>
</tr>
</thead>
<tbody>
@foreach (var materialResult in packResult.MaterialResults.Where(mr => mr.ToBePurchasedBins.Count > 0))
{
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
{
<tr>
<td>@materialResult.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches(group.Key)</td>
<td class="text-end">@group.Count()</td>
</tr>
}
}
</tbody>
<tfoot>
<tr class="fw-bold">
<td colspan="2">Total Required</td>
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
</div>
<!-- Cut Lists by Material -->
@foreach (var materialResult in packResult.MaterialResults)
{
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
var allBins = materialResult.InStockBins
.Select(b => new { Bin = b, Source = "Stock" })
.Concat(materialResult.ToBePurchasedBins
.Select(b => new { Bin = b, Source = "Purchase" }))
.ToList();
<div class="card mb-4 cutlist-material-card">
<div class="card-header cutlist-material-screen-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">@materialResult.Material.DisplayName</h5>
<span class="text-muted">
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
&middot; @materialSummary.TotalPieces pieces
&middot; @materialSummary.Efficiency.ToString("F1")% efficiency
</span>
</div>
</div>
<div class="card-body">
@if (materialResult.PackResult.ItemsNotUsed.Count > 0)
{
<div class="alert alert-danger">
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> —
not enough stock quantity entered for this job, no stock lengths configured, or parts too long.
</div>
}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr class="cutlist-material-print-header">
<th colspan="4">
<span class="cutlist-material-name">@materialResult.Material.DisplayName</span>
<span class="cutlist-material-stats">
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
&middot; @materialSummary.TotalPieces pieces
&middot; @materialSummary.Efficiency.ToString("F1")% efficiency
</span>
</th>
</tr>
<tr>
<th style="width: 50px;">#</th>
<th style="white-space: nowrap;">Stock Length</th>
<th>Cuts</th>
<th style="width: 120px; white-space: nowrap;">Waste</th>
</tr>
</thead>
<tbody>
@{ var binNum = 1; }
@foreach (var entry in allBins)
{
<tr>
<td>@binNum</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
<td>
@foreach (var item in entry.Bin.Items)
{
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
@if (!string.IsNullOrWhiteSpace(item.Name))
{
<span class="cut-part-badge-name">@item.Name</span>
}
<span class="cut-part-badge-length">@ArchUnits.FormatFromInches(item.Length)</span>
</span>
}
</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
</tr>
binNum++;
}
</tbody>
</table>
</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 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))!;
}
finally
{
optimizing = false;
}
}
private async Task LockJob()
{
lockingJob = true;
try
{
await JobService.LockAsync(Id!.Value);
job = (await JobService.GetByIdAsync(Id!.Value))!;
}
finally
{
lockingJob = false;
}
}
private async Task PrintReport()
{
var filename = $"CutList - {job.Name} - {DateTime.Now:yyyy-MM-dd}";
await JS.InvokeVoidAsync("printWithTitle", filename);
}
private async Task ShowAddStockModal()
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
stockSelectedShape = null;
stockSelectedMaterialId = 0;
availableStockItems.Clear();
stockErrorMessage = null;
importErrorMessage = null;
importCandidates.Clear();
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
showStockModal = true;
if (job.Parts.Count > 0)
{
await LoadImportCandidatesAsync();
}
}
private void CloseStockModal()
{
showStockModal = false;
editingStock = null;
newStock = new();
importCandidates.Clear();
stockErrorMessage = null;
importErrorMessage = null;
}
private async Task OnStockShapeChanged()
{
stockSelectedMaterialId = 0;
newStock.MaterialId = 0;
newStock.StockItemId = null;
availableStockItems.Clear();
}
private async Task OnStockMaterialChanged()
{
newStock.MaterialId = stockSelectedMaterialId;
newStock.StockItemId = null;
await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
}
private async Task EditStock(JobStock stock)
{
editingStock = stock;
newStock = new JobStock
{
Id = stock.Id,
JobId = stock.JobId,
MaterialId = stock.MaterialId,
StockItemId = stock.StockItemId,
LengthInches = stock.LengthInches,
Quantity = stock.Quantity,
IsCustomLength = stock.IsCustomLength,
Priority = stock.Priority,
SortOrder = stock.SortOrder
};
stockSelectedShape = stock.Material?.Shape;
stockSelectedMaterialId = stock.MaterialId;
stockErrorMessage = null;
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
showStockModal = true;
if (!stock.IsCustomLength)
{
// Load the candidate stock lengths without going through OnStockMaterialChanged,
// which would reset newStock.StockItemId and blank the prefilled selection.
// Awaited (not fire-and-forget) so Blazor re-renders once the list arrives.
await LoadAvailableStockItemsAsync(stock.MaterialId);
}
}
private async Task LoadAvailableStockItemsAsync(int materialId)
{
availableStockItems = materialId > 0
? await JobService.GetAvailableStockForMaterialAsync(materialId)
: new List<StockItem>();
}
private async Task SaveStockFromInventoryAsync()
{
stockErrorMessage = null;
if (!stockSelectedShape.HasValue)
{
stockErrorMessage = "Please select a shape";
return;
}
if (stockSelectedMaterialId == 0)
{
stockErrorMessage = "Please select a size";
return;
}
if (!newStock.StockItemId.HasValue)
{
stockErrorMessage = "Please select a stock length";
return;
}
if (newStock.Quantity < -1 || newStock.Quantity == 0)
{
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
return;
}
var selectedStock = availableStockItems.FirstOrDefault(s => s.Id == newStock.StockItemId);
if (selectedStock == null)
{
stockErrorMessage = "Selected stock not found";
return;
}
newStock.MaterialId = stockSelectedMaterialId;
newStock.LengthInches = selectedStock.LengthInches;
newStock.IsCustomLength = false;
if (editingStock == null)
{
await JobService.AddStockAsync(newStock);
}
else
{
await JobService.UpdateStockAsync(newStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = false;
editingStock = null;
packResult = null;
summary = null;
}
private async Task SaveCustomStockAsync()
{
stockErrorMessage = null;
if (!stockSelectedShape.HasValue)
{
stockErrorMessage = "Please select a shape";
return;
}
if (newStock.MaterialId == 0)
{
stockErrorMessage = "Please select a size";
return;
}
if (newStock.LengthInches <= 0)
{
stockErrorMessage = "Length must be greater than zero";
return;
}
if (newStock.Quantity < -1 || newStock.Quantity == 0)
{
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
return;
}
newStock.StockItemId = null;
newStock.IsCustomLength = true;
if (editingStock == null)
{
await JobService.AddStockAsync(newStock);
}
else
{
await JobService.UpdateStockAsync(newStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = 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;
}
// Loads inventory stock candidates for the modal's "From Inventory" tab
private async Task LoadImportCandidatesAsync()
{
importErrorMessage = null;
importCandidates.Clear();
loadingImport = true;
try
{
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
var existingStockItemIds = job.Stock
.Where(s => s.StockItemId.HasValue)
.Select(s => s.StockItemId!.Value)
.ToHashSet();
foreach (var materialId in materialIds)
{
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
{
importCandidates.Add(new ImportStockCandidate
{
StockItem = item,
Selected = true,
Quantity = -1,
Priority = 10
});
}
}
}
catch (Exception ex)
{
importErrorMessage = $"Error loading stock: {ex.Message}";
}
finally
{
loadingImport = false;
}
}
private void ToggleAllImportCandidates(bool selected)
{
foreach (var c in importCandidates)
c.Selected = selected;
}
private async Task ImportSelectedStockAsync()
{
importErrorMessage = null;
var selected = importCandidates.Where(c => c.Selected).ToList();
if (selected.Count == 0)
{
importErrorMessage = "No items selected";
return;
}
try
{
foreach (var candidate in selected)
{
var jobStock = new JobStock
{
JobId = Id!.Value,
MaterialId = candidate.StockItem.MaterialId,
StockItemId = candidate.StockItem.Id,
LengthInches = candidate.StockItem.LengthInches,
Quantity = candidate.Quantity,
Priority = candidate.Priority,
IsCustomLength = false
};
await JobService.AddStockAsync(jobStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
}
catch (Exception ex)
{
importErrorMessage = $"Error importing stock: {ex.Message}";
}
}
private class PartRow
{
public decimal LengthInches { get; set; }
public int Quantity { get; set; } = 1;
public string? Name { get; set; }
}
private class ImportStockCandidate
{
public StockItem StockItem { get; set; } = null!;
public bool Selected { get; set; } = true;
public int Quantity { get; set; } = -1;
public int Priority { get; set; } = 10;
}
}