Files
CutList/CutList.Web/Components/Pages/Jobs/Edit.razor
AJ Isaacs a226a1f652 feat: Redesign job editor with multi-row parts and unified cut list results
- Part form now supports adding multiple parts at once via a table with
  add/remove row controls; edit mode stays single-row
- Shape and size dropdowns lock when editing an existing part
- Results tab replaces split in-stock/purchase cards with a unified table
  per material showing source badges (Stock/Purchase) for each bar
- New Purchase List card summarizes materials to order with quantities
- Print styles use repeating thead headers per material for multi-page
  cut lists; large cards can now break across pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:01:01 -05:00

1568 lines
63 KiB
Plaintext

@page "/jobs/new"
@page "/jobs/{Id:int}"
@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
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(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> — materials ordered 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 (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>
}
@* Import Stock Modal *@
@if (showImportModal)
{
<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">Import Stock from Inventory</h5>
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
</div>
<div class="modal-body">
@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>On Hand</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>@candidate.StockItem.QuantityOnHand</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>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
@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>
}
</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 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 form
private bool showStockForm;
private bool showCustomStockForm;
private JobStock newStock = new();
private JobStock? editingStock;
private string? stockErrorMessage;
private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new();
// Import modal
private bool showImportModal;
private bool loadingImport;
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()
{
if (Id.HasValue)
{
await JobService.UnlockAsync(Id.Value);
job = (await JobService.GetByIdAsync(Id.Value))!;
}
}
protected override async Task OnInitializedAsync()
{
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)
{
LoadSavedResults();
}
}
else
{
// Set default cutting tool for new jobs
var defaultTool = await JobService.GetDefaultCuttingToolAsync();
if (defaultTool != null)
{
job.CuttingToolId = defaultTool.Id;
}
}
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">
<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)</td>
<td>@part.Quantity</td>
<td>@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name)</td>
<td>
@if (!job.IsLocked)
{
<button class="btn btn-sm btn-outline-primary me-1" @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>
}
</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)
{
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
}
</div>
<div class="card-body">
@if (showStockForm)
{
@RenderStockFromInventoryForm()
}
else if (showCustomStockForm)
{
@RenderCustomStockForm()
}
@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">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
</div>
}
else
{
@RenderStockTable()
}
</div>
</div>
};
private RenderFragment RenderStockFromInventoryForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
<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) (@stock.QuantityOnHand available)</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" />
</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 class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
};
private RenderFragment RenderCustomStockForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
<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 class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveCustomStockAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</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)</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)
{
<button class="btn btn-sm btn-outline-primary me-1" @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>
}
</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.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>
<!-- Purchase List -->
<div class="card mb-4 print-purchase-list">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
@if (summary.TotalToBePurchasedBins > 0)
{
@if (addedToOrderList)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus me-1"></i>Add to Order List
</button>
}
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
}
else
{
@if (addedToOrderList)
{
<div class="alert alert-success py-2 mb-3">
Items added to order list. <a href="orders">View Orders</a>
</div>
}
<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</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> —
No stock lengths available 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="5">
<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="width: 90px;">Source</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>
@if (entry.Source == "Stock")
{
<span class="badge bg-success">Stock</span>
}
else
{
<span class="badge bg-warning text-dark">Purchase</span>
}
</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
<td>
@foreach (var item in entry.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 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))!;
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()
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
stockSelectedShape = null;
showStockForm = false;
showCustomStockForm = true;
stockErrorMessage = null;
}
private void CancelStockForm()
{
showStockForm = false;
showCustomStockForm = false;
editingStock = 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;
if (stockSelectedMaterialId > 0)
{
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
}
else
{
availableStockItems.Clear();
}
}
private void 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;
if (stock.IsCustomLength)
{
showStockForm = false;
showCustomStockForm = true;
}
else
{
showStockForm = true;
showCustomStockForm = false;
_ = OnStockMaterialChanged();
}
}
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)
{
stockErrorMessage = "Quantity must be at least 1";
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))!;
showStockForm = 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))!;
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
private async Task ShowImportModal()
{
importErrorMessage = null;
importCandidates.Clear();
loadingImport = true;
showImportModal = 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 CloseImportModal()
{
showImportModal = false;
importCandidates.Clear();
importErrorMessage = null;
}
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))!;
showImportModal = 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;
}
}