@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 @(IsNew ? "New Job" : job.DisplayName)

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

Back to Jobs
@if (!IsNew && job.IsLocked) {
This job is locked — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
} @if (!string.IsNullOrEmpty(loadError)) {
Error loading job
@loadError
} else if (loading) {

Loading...

} else if (IsNew) {
@RenderDetailsForm()
} else {
@if (activeTab == Tab.Details) {
@RenderDetailsForm()
} else if (activeTab == Tab.Parts) { @RenderPartsTab() } else if (activeTab == Tab.Stock) { @RenderStockTab() } else if (activeTab == Tab.Results) { @RenderResultsTab() }
} @* Part Modal Dialog *@ @if (showPartForm) { } @* Add/Edit Stock Modal *@ @if (showStockModal) { } @code { private enum Tab { Details, Parts, Stock, Results } [Parameter] public int? Id { get; set; } private Job job = new(); private List materials = new(); private List 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 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 availableStockItems = new(); private bool loadingImport; private List importCandidates = new(); private string? importErrorMessage; // Results tab private MultiMaterialPackResult? packResult; private MultiMaterialPackingSummary? summary; private bool optimizing; private bool lockingJob; 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() { 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 => {
Job Details
@if (!IsNew) {
}
@foreach (var tool in cuttingTools) { }
@if (!string.IsNullOrEmpty(jobErrorMessage)) {
@jobErrorMessage
}
Back
}; private RenderFragment RenderPartsTab() => __builder => {
Parts to Cut
@if (!job.IsLocked) { }
@if (job.Parts.Count == 0) {

No parts added yet.

Add the parts you need to cut, selecting the material for each.

} else {
@foreach (var part in job.Parts) { }
Material Length Qty Name Actions
@part.Material.DisplayName @ArchUnits.FormatFromInches((double)part.LengthInches) (@(part.LengthInches)") @part.Quantity @(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name) @if (!job.IsLocked) { }
Total: @job.Parts.Sum(p => p.Quantity) pieces
}
}; 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 { 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 => {
Stock for This Job
@if (!job.IsLocked) { }
@if (job.Stock.Count == 0) {

No stock configured for this job.

Add stock from your inventory or define custom lengths.

If no stock is selected, the optimizer will use all available stock for the materials in your parts list.

} else { @RenderStockTable() }
}; private RenderFragment RenderStockTable() => __builder => {
@foreach (var stock in job.Stock.OrderBy(s => s.Material?.Shape).ThenBy(s => s.Material?.Size).ThenBy(s => s.Priority)) { }
Material Length Qty Priority Source Actions
@stock.Material?.DisplayName @ArchUnits.FormatFromInches((double)stock.LengthInches) (@(stock.LengthInches)") @(stock.Quantity == -1 ? "Unlimited" : stock.Quantity.ToString()) @stock.Priority @if (stock.IsCustomLength) { Custom } else { Inventory } @if (!job.IsLocked) { }
}; // Results tab private RenderFragment RenderResultsTab() => __builder => { @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); var allBins = materialResult.InStockBins .Select(b => new { Bin = b, Source = "Stock" }) .Concat(materialResult.ToBePurchasedBins .Select(b => new { Bin = b, Source = "Purchase" })) .ToList();
@materialResult.Material.DisplayName
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars · @materialSummary.TotalPieces pieces · @materialSummary.Efficiency.ToString("F1")% efficiency
@if (materialResult.PackResult.ItemsNotUsed.Count > 0) {
@materialResult.PackResult.ItemsNotUsed.Count items not placed — not enough stock quantity entered for this job, no stock lengths configured, or parts too long.
}
@{ var binNum = 1; } @foreach (var entry in allBins) { binNum++; }
@materialResult.Material.DisplayName @(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars · @materialSummary.TotalPieces pieces · @materialSummary.Efficiency.ToString("F1")% efficiency
# Stock Length Cuts Waste
@binNum @ArchUnits.FormatFromInches(entry.Bin.Length) @foreach (var item in entry.Bin.Items) { @(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") } @ArchUnits.FormatFromInches(entry.Bin.RemainingLength)
} } else if (!optimizing) {

Click Optimize to calculate the most efficient cut list.

} } }; 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; 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; if (stockSelectedMaterialId > 0) { availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId); } else { availableStockItems.Clear(); } } 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(); } 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; } }