@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.

Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.

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