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

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

Back to Jobs
@if (!IsNew && job.IsLocked) {
This job is locked — materials ordered on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
} @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) { } @* Import Stock Modal *@ @if (showImportModal) { } @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 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; // 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 availableStockItems = new(); // Import modal private bool showImportModal; private bool loadingImport; private List importCandidates = new(); private string? importErrorMessage; // Results tab private MultiMaterialPackResult? packResult; private MultiMaterialPackingSummary? summary; private bool optimizing; private bool addingToOrderList; private bool addedToOrderList; 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() { 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 => {
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.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; showPartForm = true; partErrorMessage = null; } private void OnShapeChanged() { newPart.MaterialId = 0; } 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; 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 (newPart.MaterialId == 0) { partErrorMessage = "Please select a size"; return; } if (newPart.LengthInches <= 0) { partErrorMessage = "Length must be greater than zero"; return; } if (newPart.Quantity < 1) { partErrorMessage = "Quantity must be at least 1"; return; } if (editingPart == null) { await JobService.AddPartAsync(newPart); } else { await JobService.UpdatePartAsync(newPart); } 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 (showStockForm) { @RenderStockFromInventoryForm() } else if (showCustomStockForm) { @RenderCustomStockForm() } @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 RenderStockFromInventoryForm() => __builder => {
@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")
Lower = used first
@if (!string.IsNullOrEmpty(stockErrorMessage)) {
@stockErrorMessage
}
}; private RenderFragment RenderCustomStockForm() => __builder => {
@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")
Use -1 for unlimited
Lower = used first
@if (!string.IsNullOrEmpty(stockErrorMessage)) {
@stockErrorMessage
}
}; 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.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);

@materialResult.Material.DisplayName

@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
@materialSummary.TotalPieces pieces
@materialSummary.Efficiency.ToString("F1")% efficiency
@materialSummary.InStockBins in stock
@materialSummary.ToBePurchasedBins to purchase
@if (materialResult.PackResult.ItemsNotUsed.Count > 0) {
@materialResult.PackResult.ItemsNotUsed.Count items not placed - No stock lengths available or parts too long.
} @if (materialResult.InStockBins.Count > 0) {
In Stock (@materialResult.InStockBins.Count bars)
@RenderBinList(materialResult.InStockBins) } @if (materialResult.ToBePurchasedBins.Count > 0) {
To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)
@RenderBinList(materialResult.ToBePurchasedBins)
Order Summary:
    @foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key)) {
  • @group.Count() x @ArchUnits.FormatFromInches(group.Key)
  • }
}
} } else if (!optimizing) {

Click Optimize to calculate the most efficient cut list.

} } }; private RenderFragment RenderBinList(List bins) => __builder => {
@{ var binNumber = 1; } @foreach (var bin in bins) { binNumber++; }
# Stock Length Cuts Waste
@binNumber @ArchUnits.FormatFromInches(bin.Length) @foreach (var item in bin.Items) { @(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") } @ArchUnits.FormatFromInches(bin.RemainingLength)
}; 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(); 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 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; } }