- Part form now supports adding multiple parts at once via a table with add/remove row controls; edit mode stays single-row - Shape and size dropdowns lock when editing an existing part - Results tab replaces split in-stock/purchase cards with a unified table per material showing source badges (Stock/Purchase) for each bar - New Purchase List card summarizes materials to order with quantities - Print styles use repeating thead headers per material for multi-page cut lists; large cards can now break across pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1568 lines
63 KiB
Plaintext
1568 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 (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 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()
|
|
{
|
|
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 =>
|
|
{
|
|
<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)</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)</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="5">
|
|
<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="width: 90px;">Source</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>
|
|
@if (entry.Source == "Stock")
|
|
{
|
|
<span class="badge bg-success">Stock</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-warning text-dark">Purchase</span>
|
|
}
|
|
</td>
|
|
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
|
|
<td>
|
|
@foreach (var item in entry.Bin.Items)
|
|
{
|
|
<span class="badge bg-primary me-1">
|
|
@(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;
|
|
}
|
|
}
|