Adds a Dockerfile and Gitea Actions workflow to build/push the CutList.Web image to the registry on push to master, enabling containerized deployment to forge. Also updates CLAUDE.md to reflect the CutList.Mcp project, REST API surface, and CatalogService, and tweaks the cut-length badge styling in the job editor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1572 lines
63 KiB
Plaintext
1572 lines
63 KiB
Plaintext
@page "/jobs/new"
|
|
@page "/jobs/{Id:int}"
|
|
@inject JobService JobService
|
|
@inject MaterialService MaterialService
|
|
@inject StockItemService StockItemService
|
|
@inject CutListPackingService PackingService
|
|
@inject PurchaseItemService PurchaseItemService
|
|
@inject NavigationManager Navigation
|
|
@inject IJSRuntime JS
|
|
@using CutList.Core
|
|
@using CutList.Core.Nesting
|
|
@using CutList.Core.Formatting
|
|
@using CutList.Web.Data.Entities
|
|
|
|
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
|
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
|
</div>
|
|
|
|
@if (!IsNew && job.IsLocked)
|
|
{
|
|
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<i class="bi bi-lock-fill me-2"></i>
|
|
<strong>This job is locked</strong> — materials ordered on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
|
</div>
|
|
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
|
|
<i class="bi bi-unlock"></i> Unlock Job
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (!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>
|
|
}
|
|
|
|
@* Import Stock Modal *@
|
|
@if (showImportModal)
|
|
{
|
|
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Import Stock from Inventory</h5>
|
|
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
@if (loadingImport)
|
|
{
|
|
<div class="text-center py-4">
|
|
<span class="spinner-border"></span>
|
|
<p class="mt-2 text-muted">Finding matching stock...</p>
|
|
</div>
|
|
}
|
|
else if (importCandidates.Count == 0)
|
|
{
|
|
<div class="text-center py-4 text-muted">
|
|
<p class="mb-2">No matching inventory stock found.</p>
|
|
<p class="small">Either no stock items exist for the materials in your parts, or they have already been added to this job.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(true)">Select All</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(false)">Select None</button>
|
|
</div>
|
|
<small class="text-muted">@importCandidates.Count(c => c.Selected) of @importCandidates.Count selected</small>
|
|
</div>
|
|
|
|
@foreach (var group in importCandidates
|
|
.GroupBy(c => c.StockItem.MaterialId)
|
|
.OrderBy(g => g.First().StockItem.Material.Shape)
|
|
.ThenBy(g => g.First().StockItem.Material.Size))
|
|
{
|
|
var material = group.First().StockItem.Material;
|
|
<h6 class="mt-3 mb-2 text-primary">@material.DisplayName</h6>
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 40px;"></th>
|
|
<th>Length</th>
|
|
<th>On Hand</th>
|
|
<th style="width: 120px;">Qty to Use</th>
|
|
<th style="width: 100px;">Priority</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var candidate in group.OrderByDescending(c => c.StockItem.LengthInches))
|
|
{
|
|
<tr class="@(candidate.Selected ? "" : "text-muted")">
|
|
<td>
|
|
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
|
|
</td>
|
|
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
|
|
<td>@candidate.StockItem.QuantityOnHand</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
|
|
min="-1" disabled="@(!candidate.Selected)" />
|
|
<small class="text-muted">-1 = unlimited</small>
|
|
</td>
|
|
<td>
|
|
<input type="number" class="form-control form-control-sm" @bind="candidate.Priority"
|
|
min="1" disabled="@(!candidate.Selected)" />
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
@if (!string.IsNullOrEmpty(importErrorMessage))
|
|
{
|
|
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
|
|
@if (importCandidates.Count > 0)
|
|
{
|
|
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
|
disabled="@(!importCandidates.Any(c => c.Selected))">
|
|
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private enum Tab { Details, Parts, Stock, Results }
|
|
|
|
[Parameter]
|
|
public int? Id { get; set; }
|
|
|
|
private Job job = new();
|
|
private List<Material> materials = new();
|
|
private List<CuttingTool> cuttingTools = new();
|
|
|
|
private bool loading = true;
|
|
private 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 form
|
|
private bool showStockForm;
|
|
private bool showCustomStockForm;
|
|
private JobStock newStock = new();
|
|
private JobStock? editingStock;
|
|
private string? stockErrorMessage;
|
|
private MaterialShape? stockSelectedShape;
|
|
private int stockSelectedMaterialId;
|
|
private List<StockItem> availableStockItems = new();
|
|
|
|
// Import modal
|
|
private bool showImportModal;
|
|
private bool loadingImport;
|
|
private List<ImportStockCandidate> importCandidates = new();
|
|
private string? importErrorMessage;
|
|
|
|
// Results tab
|
|
private MultiMaterialPackResult? packResult;
|
|
private MultiMaterialPackingSummary? summary;
|
|
private bool optimizing;
|
|
private bool addingToOrderList;
|
|
private bool addedToOrderList;
|
|
|
|
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
|
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
|
? Enumerable.Empty<Material>()
|
|
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
|
|
|
|
private bool IsNew => !Id.HasValue;
|
|
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
|
|
|
private async Task UnlockJob()
|
|
{
|
|
if (Id.HasValue)
|
|
{
|
|
await JobService.UnlockAsync(Id.Value);
|
|
job = (await JobService.GetByIdAsync(Id.Value))!;
|
|
}
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
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);
|
|
addedToOrderList = job.IsLocked;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Invalid JSON — treat as no results
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
}
|
|
|
|
private RenderFragment RenderDetailsForm() => __builder =>
|
|
{
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">Job Details</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<EditForm Model="job" OnValidSubmit="SaveJobAsync">
|
|
<fieldset disabled="@job.IsLocked">
|
|
@if (!IsNew)
|
|
{
|
|
<div class="mb-3">
|
|
<label class="form-label">Job Number</label>
|
|
<input type="text" class="form-control" value="@job.JobNumber" readonly />
|
|
</div>
|
|
}
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Job Name <span class="text-muted fw-normal">(optional)</span></label>
|
|
<InputText class="form-control" @bind-Value="job.Name" placeholder="Descriptive name for this job" />
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Customer <span class="text-muted fw-normal">(optional)</span></label>
|
|
<InputText class="form-control" @bind-Value="job.Customer" placeholder="Customer name" />
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Cutting Tool</label>
|
|
<InputSelect class="form-select" @bind-Value="job.CuttingToolId">
|
|
<option value="">-- Select Tool --</option>
|
|
@foreach (var tool in cuttingTools)
|
|
{
|
|
<option value="@tool.Id">@tool.Name (@tool.KerfInches" kerf)</option>
|
|
}
|
|
</InputSelect>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Notes</label>
|
|
<InputTextArea class="form-control" @bind-Value="job.Notes" rows="3" />
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(jobErrorMessage))
|
|
{
|
|
<div class="alert alert-danger">@jobErrorMessage</div>
|
|
}
|
|
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary" disabled="@savingJob">
|
|
@if (savingJob)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
}
|
|
@(IsNew ? "Create Job" : "Save")
|
|
</button>
|
|
<a href="jobs" class="btn btn-outline-secondary">Back</a>
|
|
</div>
|
|
</fieldset>
|
|
</EditForm>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private RenderFragment RenderPartsTab() => __builder =>
|
|
{
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Parts to Cut</h5>
|
|
@if (!job.IsLocked)
|
|
{
|
|
<button class="btn btn-primary" @onclick="ShowAddPartForm">Add Part</button>
|
|
}
|
|
</div>
|
|
<div class="card-body">
|
|
@if (job.Parts.Count == 0)
|
|
{
|
|
<div class="text-center py-4 text-muted">
|
|
<p class="mb-2">No parts added yet.</p>
|
|
<p class="small">Add the parts you need to cut, selecting the material for each.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Material</th>
|
|
<th>Length</th>
|
|
<th>Qty</th>
|
|
<th>Name</th>
|
|
<th style="width: 120px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var part in job.Parts)
|
|
{
|
|
<tr>
|
|
<td>@part.Material.DisplayName</td>
|
|
<td>@ArchUnits.FormatFromInches((double)part.LengthInches) <span class="text-muted">(@(part.LengthInches)")</span></td>
|
|
<td>@part.Quantity</td>
|
|
<td>@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name)</td>
|
|
<td>
|
|
@if (!job.IsLocked)
|
|
{
|
|
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="mt-3 text-muted">
|
|
Total: @job.Parts.Sum(p => p.Quantity) pieces
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private async Task SaveJobAsync()
|
|
{
|
|
jobErrorMessage = null;
|
|
savingJob = true;
|
|
|
|
try
|
|
{
|
|
if (IsNew)
|
|
{
|
|
var created = await JobService.CreateAsync(job);
|
|
Navigation.NavigateTo($"jobs/{created.Id}");
|
|
}
|
|
else
|
|
{
|
|
await JobService.UpdateAsync(job);
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
// Clear in-memory results since they were invalidated
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
savingJob = false;
|
|
}
|
|
}
|
|
|
|
// Parts methods
|
|
private void ShowAddPartForm()
|
|
{
|
|
editingPart = null;
|
|
newPart = new JobPart { JobId = Id!.Value, Quantity = 1 };
|
|
selectedShape = null;
|
|
partSelectedMaterialId = 0;
|
|
partRows = new List<PartRow> { new PartRow() };
|
|
showPartForm = true;
|
|
partErrorMessage = null;
|
|
}
|
|
|
|
private void OnShapeChanged()
|
|
{
|
|
partSelectedMaterialId = 0;
|
|
newPart.MaterialId = 0;
|
|
}
|
|
|
|
private void AddPartRow()
|
|
{
|
|
partRows.Add(new PartRow());
|
|
}
|
|
|
|
private void RemovePartRow(PartRow row)
|
|
{
|
|
partRows.Remove(row);
|
|
}
|
|
|
|
private void EditPart(JobPart part)
|
|
{
|
|
editingPart = part;
|
|
newPart = new JobPart
|
|
{
|
|
Id = part.Id,
|
|
JobId = part.JobId,
|
|
MaterialId = part.MaterialId,
|
|
Name = part.Name,
|
|
LengthInches = part.LengthInches,
|
|
Quantity = part.Quantity,
|
|
SortOrder = part.SortOrder
|
|
};
|
|
selectedShape = part.Material?.Shape;
|
|
partSelectedMaterialId = part.MaterialId;
|
|
partRows.Clear();
|
|
showPartForm = true;
|
|
partErrorMessage = null;
|
|
}
|
|
|
|
private void CancelPartForm()
|
|
{
|
|
showPartForm = false;
|
|
editingPart = null;
|
|
}
|
|
|
|
private async Task SavePartAsync()
|
|
{
|
|
partErrorMessage = null;
|
|
|
|
if (!selectedShape.HasValue)
|
|
{
|
|
partErrorMessage = "Please select a shape";
|
|
return;
|
|
}
|
|
|
|
if (partSelectedMaterialId == 0)
|
|
{
|
|
partErrorMessage = "Please select a size";
|
|
return;
|
|
}
|
|
|
|
if (editingPart != null)
|
|
{
|
|
// Edit mode: single part
|
|
if (newPart.LengthInches <= 0)
|
|
{
|
|
partErrorMessage = "Length must be greater than zero";
|
|
return;
|
|
}
|
|
if (newPart.Quantity < 1)
|
|
{
|
|
partErrorMessage = "Quantity must be at least 1";
|
|
return;
|
|
}
|
|
|
|
newPart.MaterialId = partSelectedMaterialId;
|
|
await JobService.UpdatePartAsync(newPart);
|
|
}
|
|
else
|
|
{
|
|
// Add mode: multiple rows
|
|
for (int i = 0; i < partRows.Count; i++)
|
|
{
|
|
var row = partRows[i];
|
|
if (row.LengthInches <= 0)
|
|
{
|
|
partErrorMessage = $"Row {i + 1}: Length must be greater than zero";
|
|
return;
|
|
}
|
|
if (row.Quantity < 1)
|
|
{
|
|
partErrorMessage = $"Row {i + 1}: Quantity must be at least 1";
|
|
return;
|
|
}
|
|
}
|
|
|
|
foreach (var row in partRows)
|
|
{
|
|
var part = new JobPart
|
|
{
|
|
JobId = Id!.Value,
|
|
MaterialId = partSelectedMaterialId,
|
|
LengthInches = row.LengthInches,
|
|
Quantity = row.Quantity,
|
|
Name = row.Name ?? string.Empty
|
|
};
|
|
await JobService.AddPartAsync(part);
|
|
}
|
|
}
|
|
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
showPartForm = false;
|
|
editingPart = null;
|
|
// Results were cleared by the service
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
|
|
private async Task DeletePart(JobPart part)
|
|
{
|
|
await JobService.DeletePartAsync(part.Id);
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
|
|
// Stock tab
|
|
private RenderFragment RenderStockTab() => __builder =>
|
|
{
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Stock for This Job</h5>
|
|
@if (!job.IsLocked)
|
|
{
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
|
|
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
|
Import from Inventory
|
|
</button>
|
|
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="card-body">
|
|
@if (showStockForm)
|
|
{
|
|
@RenderStockFromInventoryForm()
|
|
}
|
|
else if (showCustomStockForm)
|
|
{
|
|
@RenderCustomStockForm()
|
|
}
|
|
|
|
@if (job.Stock.Count == 0)
|
|
{
|
|
<div class="text-center py-4 text-muted">
|
|
<p class="mb-2">No stock configured for this job.</p>
|
|
<p class="small">Add stock from your inventory or define custom lengths.</p>
|
|
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@RenderStockTable()
|
|
}
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private RenderFragment RenderStockFromInventoryForm() => __builder =>
|
|
{
|
|
<div class="border rounded p-3 mb-3 bg-light">
|
|
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
|
|
<div class="row g-3">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Shape</label>
|
|
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
|
<option value="">-- Select --</option>
|
|
@foreach (var shape in DistinctShapes)
|
|
{
|
|
<option value="@shape">@shape.GetDisplayName()</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Size</label>
|
|
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
|
|
disabled="@(!stockSelectedShape.HasValue)">
|
|
<option value="0">-- Select --</option>
|
|
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
|
{
|
|
<option value="@material.Id">@material.Size</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Stock Length</label>
|
|
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
|
|
<option value="">-- Select --</option>
|
|
@foreach (var stock in availableStockItems)
|
|
{
|
|
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@stock.QuantityOnHand available)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Qty to Use</label>
|
|
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
|
</div>
|
|
</div>
|
|
<div class="row g-3 mt-1">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Priority</label>
|
|
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
|
<small class="text-muted">Lower = used first</small>
|
|
</div>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
|
{
|
|
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
|
}
|
|
<div class="mt-3 d-flex gap-2">
|
|
<button class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">
|
|
@(editingStock == null ? "Add Stock" : "Save Changes")
|
|
</button>
|
|
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private RenderFragment RenderCustomStockForm() => __builder =>
|
|
{
|
|
<div class="border rounded p-3 mb-3 bg-light">
|
|
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
|
|
<div class="row g-3">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Shape</label>
|
|
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
|
<option value="">-- Select --</option>
|
|
@foreach (var shape in DistinctShapes)
|
|
{
|
|
<option value="@shape">@shape.GetDisplayName()</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Size</label>
|
|
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
|
|
<option value="0">-- Select --</option>
|
|
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
|
{
|
|
<option value="@material.Id">@material.Size</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Length</label>
|
|
<LengthInput @bind-Value="newStock.LengthInches" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Quantity</label>
|
|
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
|
<small class="text-muted">Use -1 for unlimited</small>
|
|
</div>
|
|
</div>
|
|
<div class="row g-3 mt-1">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Priority</label>
|
|
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
|
<small class="text-muted">Lower = used first</small>
|
|
</div>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
|
{
|
|
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
|
}
|
|
<div class="mt-3 d-flex gap-2">
|
|
<button class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
|
@(editingStock == null ? "Add Stock" : "Save Changes")
|
|
</button>
|
|
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
private RenderFragment RenderStockTable() => __builder =>
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Material</th>
|
|
<th>Length</th>
|
|
<th>Qty</th>
|
|
<th>Priority</th>
|
|
<th>Source</th>
|
|
<th style="width: 120px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var stock in job.Stock.OrderBy(s => s.Material?.Shape).ThenBy(s => s.Material?.Size).ThenBy(s => s.Priority))
|
|
{
|
|
<tr>
|
|
<td>@stock.Material?.DisplayName</td>
|
|
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches) <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)
|
|
{
|
|
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
};
|
|
|
|
// Results tab
|
|
private RenderFragment RenderResultsTab() => __builder =>
|
|
{
|
|
@if (!CanOptimize)
|
|
{
|
|
<div class="alert alert-warning">
|
|
<h5 class="mb-2">Cannot Optimize</h5>
|
|
<ul class="mb-0">
|
|
@if (job.Parts.Count == 0)
|
|
{
|
|
<li>No parts defined. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Parts)">Parts tab</button> to add parts.</li>
|
|
}
|
|
@if (job.CuttingToolId == null)
|
|
{
|
|
<li>No cutting tool selected. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Details)">Details tab</button> to select a cutting tool.</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-3">
|
|
<button class="btn btn-success" @onclick="RunOptimization" disabled="@(optimizing || job.IsLocked)">
|
|
@if (optimizing)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
<text>Optimizing...</text>
|
|
}
|
|
else if (packResult != null)
|
|
{
|
|
<i class="bi bi-arrow-clockwise me-1"></i>
|
|
<text>Re-Optimize</text>
|
|
}
|
|
else
|
|
{
|
|
<i class="bi bi-scissors me-1"></i>
|
|
<text>Optimize</text>
|
|
}
|
|
</button>
|
|
@if (packResult != null)
|
|
{
|
|
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
|
<i class="bi bi-printer me-1"></i> Print Report
|
|
</button>
|
|
}
|
|
@if (job.OptimizedAt.HasValue)
|
|
{
|
|
<span class="text-muted ms-3">
|
|
Last optimized: @job.OptimizedAt.Value.ToLocalTime().ToString("g")
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
@if (packResult != null && summary != null)
|
|
{
|
|
@if (summary.TotalItemsNotPlaced > 0)
|
|
{
|
|
<div class="alert alert-warning">
|
|
<h5>Items Not Placed</h5>
|
|
<p class="mb-0">Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.</p>
|
|
</div>
|
|
}
|
|
|
|
<!-- Overall Summary Cards -->
|
|
<div class="row mb-4 print-summary">
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<div class="card text-center">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-0">@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)</h2>
|
|
<p class="card-text text-muted">Total Stock Bars</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<div class="card text-center">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
|
|
<p class="card-text text-muted">Total Pieces</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<div class="card text-center">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
|
<p class="card-text text-muted">Total Waste</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<div class="card text-center">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
|
|
<p class="card-text text-muted">Efficiency</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Purchase List -->
|
|
<div class="card mb-4 print-purchase-list">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
|
|
@if (summary.TotalToBePurchasedBins > 0)
|
|
{
|
|
@if (addedToOrderList)
|
|
{
|
|
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
|
|
@if (addingToOrderList)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
}
|
|
<i class="bi bi-cart-plus me-1"></i>Add to Order List
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
<div class="card-body">
|
|
@if (summary.TotalToBePurchasedBins == 0)
|
|
{
|
|
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
|
|
}
|
|
else
|
|
{
|
|
@if (addedToOrderList)
|
|
{
|
|
<div class="alert alert-success py-2 mb-3">
|
|
Items added to order list. <a href="orders">View Orders</a>
|
|
</div>
|
|
}
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Material</th>
|
|
<th>Length</th>
|
|
<th class="text-end">Qty</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var materialResult in packResult.MaterialResults.Where(mr => mr.ToBePurchasedBins.Count > 0))
|
|
{
|
|
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
|
|
{
|
|
<tr>
|
|
<td>@materialResult.Material.DisplayName</td>
|
|
<td>@ArchUnits.FormatFromInches(group.Key)</td>
|
|
<td class="text-end">@group.Count()</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr class="fw-bold">
|
|
<td colspan="2">Total</td>
|
|
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cut Lists by Material -->
|
|
@foreach (var materialResult in packResult.MaterialResults)
|
|
{
|
|
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
|
|
var allBins = materialResult.InStockBins
|
|
.Select(b => new { Bin = b, Source = "Stock" })
|
|
.Concat(materialResult.ToBePurchasedBins
|
|
.Select(b => new { Bin = b, Source = "Purchase" }))
|
|
.ToList();
|
|
|
|
<div class="card mb-4 cutlist-material-card">
|
|
<div class="card-header cutlist-material-screen-header">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">@materialResult.Material.DisplayName</h5>
|
|
<span class="text-muted">
|
|
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
|
|
· @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> —
|
|
No stock lengths available or parts too long.
|
|
</div>
|
|
}
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped mb-0">
|
|
<thead>
|
|
<tr class="cutlist-material-print-header">
|
|
<th colspan="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;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
|
|
<td>
|
|
@foreach (var item in entry.Bin.Items)
|
|
{
|
|
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;">
|
|
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
|
</span>
|
|
}
|
|
</td>
|
|
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
|
|
</tr>
|
|
binNum++;
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
else if (!optimizing)
|
|
{
|
|
<div class="text-center py-5 text-muted">
|
|
<i class="bi bi-scissors display-4"></i>
|
|
<p class="mt-3">Click <strong>Optimize</strong> to calculate the most efficient cut list.</p>
|
|
</div>
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
private async Task RunOptimization()
|
|
{
|
|
optimizing = true;
|
|
try
|
|
{
|
|
var kerf = job.CuttingTool?.KerfInches ?? 0.125m;
|
|
packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null);
|
|
summary = PackingService.GetSummary(packResult);
|
|
|
|
// Save to database
|
|
var json = PackingService.SerializeResult(packResult);
|
|
await JobService.SaveOptimizationResultAsync(Id!.Value, json, DateTime.UtcNow);
|
|
|
|
// Refresh job to get updated OptimizedAt
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
addedToOrderList = job.IsLocked;
|
|
}
|
|
finally
|
|
{
|
|
optimizing = false;
|
|
}
|
|
}
|
|
|
|
private async Task AddToOrderList()
|
|
{
|
|
addingToOrderList = true;
|
|
try
|
|
{
|
|
var purchaseItems = new List<PurchaseItem>();
|
|
var stockItems = await StockItemService.GetAllAsync();
|
|
|
|
foreach (var materialResult in packResult!.MaterialResults)
|
|
{
|
|
if (materialResult.ToBePurchasedBins.Count == 0) continue;
|
|
|
|
var materialId = materialResult.Material.Id;
|
|
|
|
// Group bins by length to consolidate quantities
|
|
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
|
|
{
|
|
var lengthInches = (decimal)group.Key;
|
|
var quantity = group.Count();
|
|
|
|
// Find the matching stock item
|
|
var stockItem = stockItems.FirstOrDefault(s =>
|
|
s.MaterialId == materialId && s.LengthInches == lengthInches);
|
|
|
|
if (stockItem != null)
|
|
{
|
|
purchaseItems.Add(new PurchaseItem
|
|
{
|
|
StockItemId = stockItem.Id,
|
|
Quantity = quantity,
|
|
JobId = Id!.Value,
|
|
Status = PurchaseItemStatus.Pending
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (purchaseItems.Count > 0)
|
|
{
|
|
await PurchaseItemService.CreateBulkAsync(purchaseItems);
|
|
}
|
|
|
|
await JobService.LockAsync(Id!.Value);
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
addedToOrderList = true;
|
|
}
|
|
finally
|
|
{
|
|
addingToOrderList = false;
|
|
}
|
|
}
|
|
|
|
private async Task PrintReport()
|
|
{
|
|
var filename = $"CutList - {job.Name} - {DateTime.Now:yyyy-MM-dd}";
|
|
await JS.InvokeVoidAsync("printWithTitle", filename);
|
|
}
|
|
|
|
private void ShowAddCustomStock()
|
|
{
|
|
editingStock = null;
|
|
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
|
|
stockSelectedShape = null;
|
|
showStockForm = false;
|
|
showCustomStockForm = true;
|
|
stockErrorMessage = null;
|
|
}
|
|
|
|
private void CancelStockForm()
|
|
{
|
|
showStockForm = false;
|
|
showCustomStockForm = false;
|
|
editingStock = null;
|
|
}
|
|
|
|
private async Task OnStockShapeChanged()
|
|
{
|
|
stockSelectedMaterialId = 0;
|
|
newStock.MaterialId = 0;
|
|
newStock.StockItemId = null;
|
|
availableStockItems.Clear();
|
|
}
|
|
|
|
private async Task OnStockMaterialChanged()
|
|
{
|
|
newStock.MaterialId = stockSelectedMaterialId;
|
|
newStock.StockItemId = null;
|
|
if (stockSelectedMaterialId > 0)
|
|
{
|
|
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
|
|
}
|
|
else
|
|
{
|
|
availableStockItems.Clear();
|
|
}
|
|
}
|
|
|
|
private void EditStock(JobStock stock)
|
|
{
|
|
editingStock = stock;
|
|
newStock = new JobStock
|
|
{
|
|
Id = stock.Id,
|
|
JobId = stock.JobId,
|
|
MaterialId = stock.MaterialId,
|
|
StockItemId = stock.StockItemId,
|
|
LengthInches = stock.LengthInches,
|
|
Quantity = stock.Quantity,
|
|
IsCustomLength = stock.IsCustomLength,
|
|
Priority = stock.Priority,
|
|
SortOrder = stock.SortOrder
|
|
};
|
|
stockSelectedShape = stock.Material?.Shape;
|
|
stockSelectedMaterialId = stock.MaterialId;
|
|
stockErrorMessage = null;
|
|
|
|
if (stock.IsCustomLength)
|
|
{
|
|
showStockForm = false;
|
|
showCustomStockForm = true;
|
|
}
|
|
else
|
|
{
|
|
showStockForm = true;
|
|
showCustomStockForm = false;
|
|
_ = OnStockMaterialChanged();
|
|
}
|
|
}
|
|
|
|
private async Task SaveStockFromInventoryAsync()
|
|
{
|
|
stockErrorMessage = null;
|
|
|
|
if (!stockSelectedShape.HasValue)
|
|
{
|
|
stockErrorMessage = "Please select a shape";
|
|
return;
|
|
}
|
|
|
|
if (stockSelectedMaterialId == 0)
|
|
{
|
|
stockErrorMessage = "Please select a size";
|
|
return;
|
|
}
|
|
|
|
if (!newStock.StockItemId.HasValue)
|
|
{
|
|
stockErrorMessage = "Please select a stock length";
|
|
return;
|
|
}
|
|
|
|
if (newStock.Quantity < 1)
|
|
{
|
|
stockErrorMessage = "Quantity must be at least 1";
|
|
return;
|
|
}
|
|
|
|
var selectedStock = availableStockItems.FirstOrDefault(s => s.Id == newStock.StockItemId);
|
|
if (selectedStock == null)
|
|
{
|
|
stockErrorMessage = "Selected stock not found";
|
|
return;
|
|
}
|
|
|
|
newStock.MaterialId = stockSelectedMaterialId;
|
|
newStock.LengthInches = selectedStock.LengthInches;
|
|
newStock.IsCustomLength = false;
|
|
|
|
if (editingStock == null)
|
|
{
|
|
await JobService.AddStockAsync(newStock);
|
|
}
|
|
else
|
|
{
|
|
await JobService.UpdateStockAsync(newStock);
|
|
}
|
|
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
showStockForm = false;
|
|
editingStock = null;
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
|
|
private async Task SaveCustomStockAsync()
|
|
{
|
|
stockErrorMessage = null;
|
|
|
|
if (!stockSelectedShape.HasValue)
|
|
{
|
|
stockErrorMessage = "Please select a shape";
|
|
return;
|
|
}
|
|
|
|
if (newStock.MaterialId == 0)
|
|
{
|
|
stockErrorMessage = "Please select a size";
|
|
return;
|
|
}
|
|
|
|
if (newStock.LengthInches <= 0)
|
|
{
|
|
stockErrorMessage = "Length must be greater than zero";
|
|
return;
|
|
}
|
|
|
|
if (newStock.Quantity < -1 || newStock.Quantity == 0)
|
|
{
|
|
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
|
|
return;
|
|
}
|
|
|
|
newStock.StockItemId = null;
|
|
newStock.IsCustomLength = true;
|
|
|
|
if (editingStock == null)
|
|
{
|
|
await JobService.AddStockAsync(newStock);
|
|
}
|
|
else
|
|
{
|
|
await JobService.UpdateStockAsync(newStock);
|
|
}
|
|
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
showCustomStockForm = false;
|
|
editingStock = null;
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
|
|
private async Task DeleteStock(JobStock stock)
|
|
{
|
|
await JobService.DeleteStockAsync(stock.Id);
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
|
|
// Import modal methods
|
|
private async Task ShowImportModal()
|
|
{
|
|
importErrorMessage = null;
|
|
importCandidates.Clear();
|
|
loadingImport = true;
|
|
showImportModal = true;
|
|
|
|
try
|
|
{
|
|
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
|
|
var existingStockItemIds = job.Stock
|
|
.Where(s => s.StockItemId.HasValue)
|
|
.Select(s => s.StockItemId!.Value)
|
|
.ToHashSet();
|
|
|
|
foreach (var materialId in materialIds)
|
|
{
|
|
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
|
|
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
|
|
{
|
|
importCandidates.Add(new ImportStockCandidate
|
|
{
|
|
StockItem = item,
|
|
Selected = true,
|
|
Quantity = -1,
|
|
Priority = 10
|
|
});
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
importErrorMessage = $"Error loading stock: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
loadingImport = false;
|
|
}
|
|
}
|
|
|
|
private void CloseImportModal()
|
|
{
|
|
showImportModal = false;
|
|
importCandidates.Clear();
|
|
importErrorMessage = null;
|
|
}
|
|
|
|
private void ToggleAllImportCandidates(bool selected)
|
|
{
|
|
foreach (var c in importCandidates)
|
|
c.Selected = selected;
|
|
}
|
|
|
|
private async Task ImportSelectedStockAsync()
|
|
{
|
|
importErrorMessage = null;
|
|
var selected = importCandidates.Where(c => c.Selected).ToList();
|
|
if (selected.Count == 0)
|
|
{
|
|
importErrorMessage = "No items selected";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var candidate in selected)
|
|
{
|
|
var jobStock = new JobStock
|
|
{
|
|
JobId = Id!.Value,
|
|
MaterialId = candidate.StockItem.MaterialId,
|
|
StockItemId = candidate.StockItem.Id,
|
|
LengthInches = candidate.StockItem.LengthInches,
|
|
Quantity = candidate.Quantity,
|
|
Priority = candidate.Priority,
|
|
IsCustomLength = false
|
|
};
|
|
await JobService.AddStockAsync(jobStock);
|
|
}
|
|
|
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
|
showImportModal = false;
|
|
importCandidates.Clear();
|
|
packResult = null;
|
|
summary = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
importErrorMessage = $"Error importing stock: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private class PartRow
|
|
{
|
|
public decimal LengthInches { get; set; }
|
|
public int Quantity { get; set; } = 1;
|
|
public string? Name { get; set; }
|
|
}
|
|
|
|
private class ImportStockCandidate
|
|
{
|
|
public StockItem StockItem { get; set; } = null!;
|
|
public bool Selected { get; set; } = true;
|
|
public int Quantity { get; set; } = -1;
|
|
public int Priority { get; set; } = 10;
|
|
}
|
|
}
|