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