Compare commits

..
5 Commits
Author SHA1 Message Date
ajandClaude Sonnet 5 ec16dd2c9b fix: polish unified add-stock modal (Edit.razor)
Build CutList image / build-and-push (push) Successful in 24s
Apply four low-risk fixes from final code review of the consolidated
add-stock modal: reset newStock on modal close, dedupe stock-item
loading through the existing helper, correct stale empty-state copy
about inventory fallback, and remove a stray blank line left from the
old form deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:32:51 -04:00
ajandClaude Sonnet 5 0c5ecf716a fix: prefill Stock Length dropdown when editing inventory-sourced stock
Editing an inventory-sourced job stock row opened the Edit Stock modal
with the Stock Length dropdown blank instead of showing the row's
current length. EditStock() correctly set newStock.StockItemId from
the row, but then called the fire-and-forget OnStockMaterialChanged(),
which (a) unconditionally reset StockItemId to null, and (b) never
triggered a re-render since its Task wasn't awaited by the event
handler pipeline. Made EditStock async and await a dedicated loader
that populates availableStockItems without touching StockItemId, so
Blazor re-renders once the candidate lengths arrive.

Found during Task 2 manual verification of the unified Add Stock
modal (Step 6).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:23:49 -04:00
aj c66099481b docs: add implementation plan for unified add/edit stock modal 2026-08-01 19:08:28 -04:00
aj 9020ba80c1 feat: unify add/edit stock UI into a single modal on Job Edit page
Replaces the three separate surfaces (bulk import modal, inline custom-length
form, inline edit forms) with one "Add Stock" button and modal that handles
both add and edit, for both inventory-sourced and custom-length stock.
2026-08-01 19:02:27 -04:00
aj 2cb9c5ec8d docs: add design spec for unified add/edit stock modal
Job Edit page's Stock tab currently has three inconsistent entry points
(bulk import modal, inline custom-length form, inline edit forms) for
what should be a single add/edit stock flow.
2026-08-01 17:52:49 -04:00
3 changed files with 1138 additions and 243 deletions
+184 -182
View File
@@ -229,17 +229,40 @@ else
</div> </div>
} }
@* Import Stock Modal *@ @* Add/Edit Stock Modal *@
@if (showImportModal) @if (showStockModal)
{ {
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);"> <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-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Import Stock from Inventory</h5> <h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
<button type="button" class="btn-close" @onclick="CloseImportModal"></button> <button type="button" class="btn-close" @onclick="CloseStockModal"></button>
</div> </div>
<div class="modal-body"> <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) @if (loadingImport)
{ {
<div class="text-center py-4"> <div class="text-center py-4">
@@ -307,9 +330,109 @@ else
{ {
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div> <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button> <button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
@if (importCandidates.Count > 0) @if (importCandidates.Count > 0)
{ {
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync" <button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
@@ -317,6 +440,17 @@ else
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "") Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
</button> </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>
</div> </div>
@@ -350,18 +484,16 @@ else
private int partSelectedMaterialId; private int partSelectedMaterialId;
private List<PartRow> partRows = new(); private List<PartRow> partRows = new();
// Stock form // Stock modal (unified add/edit, both inventory-sourced and custom-length)
private bool showStockForm; private enum StockModalTab { Inventory, Custom }
private bool showCustomStockForm; private bool showStockModal;
private StockModalTab stockModalTab = StockModalTab.Inventory;
private JobStock newStock = new(); private JobStock newStock = new();
private JobStock? editingStock; private JobStock? editingStock;
private string? stockErrorMessage; private string? stockErrorMessage;
private MaterialShape? stockSelectedShape; private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId; private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new(); private List<StockItem> availableStockItems = new();
// Import modal
private bool showImportModal;
private bool loadingImport; private bool loadingImport;
private List<ImportStockCandidate> importCandidates = new(); private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage; private string? importErrorMessage;
@@ -741,31 +873,16 @@ else
<h5 class="mb-0">Stock for This Job</h5> <h5 class="mb-0">Stock for This Job</h5>
@if (!job.IsLocked) @if (!job.IsLocked)
{ {
<div class="d-flex gap-2"> <button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
<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>
<div class="card-body"> <div class="card-body">
@if (showStockForm)
{
@RenderStockFromInventoryForm()
}
else if (showCustomStockForm)
{
@RenderCustomStockForm()
}
@if (job.Stock.Count == 0) @if (job.Stock.Count == 0)
{ {
<div class="text-center py-4 text-muted"> <div class="text-center py-4 text-muted">
<p class="mb-2">No stock configured for this job.</p> <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">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> <p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
</div> </div>
} }
else else
@@ -776,123 +893,6 @@ else
</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)</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>
}
<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 => private RenderFragment RenderStockTable() => __builder =>
{ {
<div class="table-responsive"> <div class="table-responsive">
@@ -1227,21 +1227,33 @@ else
await JS.InvokeVoidAsync("printWithTitle", filename); await JS.InvokeVoidAsync("printWithTitle", filename);
} }
private void ShowAddCustomStock() private async Task ShowAddStockModal()
{ {
editingStock = null; editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true }; newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
stockSelectedShape = null; stockSelectedShape = null;
showStockForm = false; stockSelectedMaterialId = 0;
showCustomStockForm = true; availableStockItems.Clear();
stockErrorMessage = null; 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 CancelStockForm() private void CloseStockModal()
{ {
showStockForm = false; showStockModal = false;
showCustomStockForm = false;
editingStock = null; editingStock = null;
newStock = new();
importCandidates.Clear();
stockErrorMessage = null;
importErrorMessage = null;
} }
private async Task OnStockShapeChanged() private async Task OnStockShapeChanged()
@@ -1256,17 +1268,10 @@ else
{ {
newStock.MaterialId = stockSelectedMaterialId; newStock.MaterialId = stockSelectedMaterialId;
newStock.StockItemId = null; newStock.StockItemId = null;
if (stockSelectedMaterialId > 0) await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
{
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
}
else
{
availableStockItems.Clear();
}
} }
private void EditStock(JobStock stock) private async Task EditStock(JobStock stock)
{ {
editingStock = stock; editingStock = stock;
newStock = new JobStock newStock = new JobStock
@@ -1284,18 +1289,23 @@ else
stockSelectedShape = stock.Material?.Shape; stockSelectedShape = stock.Material?.Shape;
stockSelectedMaterialId = stock.MaterialId; stockSelectedMaterialId = stock.MaterialId;
stockErrorMessage = null; stockErrorMessage = null;
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
showStockModal = true;
if (stock.IsCustomLength) if (!stock.IsCustomLength)
{ {
showStockForm = false; // Load the candidate stock lengths without going through OnStockMaterialChanged,
showCustomStockForm = true; // 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);
} }
else }
private async Task LoadAvailableStockItemsAsync(int materialId)
{ {
showStockForm = true; availableStockItems = materialId > 0
showCustomStockForm = false; ? await JobService.GetAvailableStockForMaterialAsync(materialId)
_ = OnStockMaterialChanged(); : new List<StockItem>();
}
} }
private async Task SaveStockFromInventoryAsync() private async Task SaveStockFromInventoryAsync()
@@ -1347,7 +1357,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false; showStockModal = false;
editingStock = null; editingStock = null;
packResult = null; packResult = null;
summary = null; summary = null;
@@ -1394,7 +1404,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false; showStockModal = false;
editingStock = null; editingStock = null;
packResult = null; packResult = null;
summary = null; summary = null;
@@ -1408,13 +1418,12 @@ else
summary = null; summary = null;
} }
// Import modal methods // Loads inventory stock candidates for the modal's "From Inventory" tab
private async Task ShowImportModal() private async Task LoadImportCandidatesAsync()
{ {
importErrorMessage = null; importErrorMessage = null;
importCandidates.Clear(); importCandidates.Clear();
loadingImport = true; loadingImport = true;
showImportModal = true;
try try
{ {
@@ -1449,13 +1458,6 @@ else
} }
} }
private void CloseImportModal()
{
showImportModal = false;
importCandidates.Clear();
importErrorMessage = null;
}
private void ToggleAllImportCandidates(bool selected) private void ToggleAllImportCandidates(bool selected)
{ {
foreach (var c in importCandidates) foreach (var c in importCandidates)
@@ -1490,7 +1492,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false; showStockModal = false;
importCandidates.Clear(); importCandidates.Clear();
packResult = null; packResult = null;
summary = null; summary = null;
@@ -0,0 +1,819 @@
# Unified Add/Edit Stock Modal Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Job Edit page's Stock tab three inconsistent add/edit UI surfaces (bulk import modal, inline custom-length form, inline edit forms) with a single "Add Stock" button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
**Architecture:** Single-file Razor component change in `CutList.Web/Components/Pages/Jobs/Edit.razor`. Three boolean visibility flags (`showStockForm`, `showCustomStockForm`, `showImportModal`) collapse into one (`showStockModal`) plus a `StockModalTab` enum (`Inventory`/`Custom`) for tab selection; the existing `editingStock` field (already present) continues to discriminate Add vs Edit mode. All markup for the three old surfaces is merged into one top-level modal block, following the same top-level-markup-block pattern already used by the existing "Part Modal Dialog" and (soon-to-be-replaced) "Import Stock Modal" blocks in this file.
**Tech Stack:** .NET 8 Blazor Server, Bootstrap 5 modal/tab markup. No automated test project exists for `CutList.Web` (confirmed: no `*.Tests.csproj` in the solution). Verification is `dotnet build` for compile correctness plus manual browser verification via the `webapp-testing` skill (Playwright) for behavior — there is no unit-test step in this plan.
## Global Constraints
- Full design spec: `docs/superpowers/specs/2026-08-01-unified-add-stock-modal-design.md` — every requirement in it must be reflected below.
- No changes to the Parts tab, its modal, `JobService`, or any API surface (spec's "Out of Scope" section).
- No change to validation rules or business logic — UI consolidation only. All four existing handler methods (`SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, and the renamed candidate-loading method) keep their existing validation logic verbatim.
- Match existing code style in this file: top-level markup blocks (not extra `RenderFragment` C# methods) for modals, mirroring the existing "Part Modal Dialog" block's inline `@if`/`else` branching.
---
### Task 1: Consolidate Stock tab add/edit UI into one modal
**Files:**
- Modify: `CutList.Web/Components/Pages/Jobs/Edit.razor`
**Interfaces:**
- Produces: `private enum StockModalTab { Inventory, Custom }`, `private bool showStockModal`, `private StockModalTab stockModalTab`, `private async Task ShowAddStockModal()`, `private void CloseStockModal()`, `private async Task LoadImportCandidatesAsync()` — these are the only new/renamed symbols; every other field/method referenced below (`newStock`, `editingStock`, `stockErrorMessage`, `stockSelectedShape`, `stockSelectedMaterialId`, `availableStockItems`, `loadingImport`, `importCandidates`, `importErrorMessage`, `ImportStockCandidate`, `job`, `DistinctShapes`, `materials`, `ArchUnits`, `OnStockShapeChanged`, `OnStockMaterialChanged`, `SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, `ToggleAllImportCandidates`, `EditStock`, `DeleteStock`, `RenderStockTable`) already exists in the file and keeps its current signature.
This is one cohesive task — the markup and the code-behind are too interdependent in a single `.razor` file to compile in a valid intermediate state, so it's implemented as one connected set of edits followed by a single build/verification gate.
- [ ] **Step 1: Replace the Stock-form and Import-modal state fields with the unified modal state**
In the `@code` block, find this region (currently around line 353-367):
```csharp
// 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;
```
Replace it with:
```csharp
// 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;
```
- [ ] **Step 2: Replace `RenderStockTab` to use a single "Add Stock" button and drop the inline form rendering**
Find (currently around line 737-777):
```csharp
// 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>
};
```
Replace it with:
```csharp
// 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">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>
};
```
- [ ] **Step 3: Delete `RenderStockFromInventoryForm` and `RenderCustomStockForm`**
Delete these two methods entirely (currently around lines 779-894, immediately after the new `RenderStockTab` and before `RenderStockTable`):
```csharp
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>
};
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>
};
```
(Their field content is reused, without the wrapping `<div class="border...">`/`<h6>`/footer-button markup, inside the new modal in Step 6.)
- [ ] **Step 4: Replace `ShowAddCustomStock`/`CancelStockForm` with `ShowAddStockModal`/`CloseStockModal`, and rename `ShowImportModal`/`CloseImportModal`**
Find (currently around line 1230-1246):
```csharp
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;
}
```
Replace it with:
```csharp
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;
importCandidates.Clear();
stockErrorMessage = null;
importErrorMessage = null;
}
```
- [ ] **Step 5: Update `EditStock` to open the unified modal**
Find (currently around line 1269-1299):
```csharp
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();
}
}
```
Replace it with:
```csharp
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;
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
showStockModal = true;
if (!stock.IsCustomLength)
{
_ = OnStockMaterialChanged();
}
}
```
- [ ] **Step 6: Update the three save/import completion handlers to close the unified modal**
In `SaveStockFromInventoryAsync` (currently around line 1349-1353), find:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false;
editingStock = null;
packResult = null;
summary = null;
}
```
(this is the end of `SaveStockFromInventoryAsync`) and replace with:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = false;
editingStock = null;
packResult = null;
summary = null;
}
```
In `SaveCustomStockAsync` (currently around line 1396-1401), find:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false;
editingStock = null;
packResult = null;
summary = null;
}
```
Replace with:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = false;
editingStock = null;
packResult = null;
summary = null;
}
```
In `ImportSelectedStockAsync` (currently around line 1492-1497), find:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
}
```
Replace with:
```csharp
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
}
```
- [ ] **Step 7: Rename `ShowImportModal` to `LoadImportCandidatesAsync` and delete `CloseImportModal`**
Find (currently around line 1411-1457):
```csharp
// 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;
}
```
Replace it with:
```csharp
// 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;
}
}
```
- [ ] **Step 8: Replace the "Import Stock Modal" markup block with the unified "Add/Edit Stock Modal" block**
Find the entire top-level block currently at lines 232-324:
```razor
@* 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>
</div>
</div>
}
```
(the full existing block — everything between `@* Import Stock Modal *@` and its closing `}`)
Replace it with:
```razor
@* 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>
}
```
Note this new block goes in the exact same position (between the "Part Modal Dialog" block and the `@code` block) that the old "Import Stock Modal" block occupied.
- [ ] **Step 9: Verify no leftover references to removed identifiers**
Run:
```bash
grep -nE "showStockForm|showCustomStockForm|showImportModal|ShowAddCustomStock|CancelStockForm|CloseImportModal\b|RenderStockFromInventoryForm|RenderCustomStockForm" CutList.Web/Components/Pages/Jobs/Edit.razor
```
Expected: no output (all removed/renamed identifiers are gone). If anything matches, fix it before proceeding — it's a leftover reference that Step 10's build may or may not catch (e.g. a stray comment).
- [ ] **Step 10: Build and verify no compile errors**
Run:
```bash
dotnet build CutList.Web/CutList.Web.csproj
```
Expected: `Build succeeded.` with 0 errors. Fix any errors (most likely causes: a missed rename, or a Razor `@`-prefix mismatch in the nested `@if` blocks from Step 8 — compare carefully against the nesting depth shown above, every nested C# control-flow keyword inside markup context needs its own `@` prefix).
- [ ] **Step 11: Commit**
```bash
git add CutList.Web/Components/Pages/Jobs/Edit.razor
git commit -m "$(cat <<'EOF'
feat: unify add/edit stock UI into a single modal on Job Edit page
Replaces the three separate surfaces (bulk import modal, inline custom-length
form, inline edit forms) with one "Add Stock" button and modal that handles
both add and edit, for both inventory-sourced and custom-length stock.
EOF
)"
```
---
### Task 2: Manual verification of every Stock tab flow
**Files:**
- None modified — this task is verification only, using the `webapp-testing` skill (Playwright) against the running app. Fix-forward edits to `CutList.Web/Components/Pages/Jobs/Edit.razor` are in scope if a scenario below reveals a bug.
**Interfaces:**
- Consumes: the running `CutList.Web` app (`dotnet run --project CutList.Web/CutList.Web.csproj`, default `http://localhost:5270`) and an existing or newly-created Job on the `/jobs/{id}` Edit page's Stock tab.
- [ ] **Step 1: Start the app**
Run (background):
```bash
dotnet run --project CutList.Web/CutList.Web.csproj
```
Expected: app listening on `http://localhost:5270`.
- [ ] **Step 2: Verify "Add Stock" opens the modal defaulting to the Inventory tab when parts exist**
Using Playwright (webapp-testing skill): navigate to a job that already has at least one part and no stock yet (create one via the Parts tab if none exists), switch to the Stock tab.
Expected: one "Add Stock" button (no separate "Import from Inventory" / "Add Custom Length" buttons). Clicking it opens a modal titled "Add Stock" with two tabs, "From Inventory" active by default, showing the bulk-select candidate table (or a "No matching inventory stock found" message if the material has no inventory stock items — either is acceptable, both are pre-existing behaviors).
- [ ] **Step 3: Verify bulk import from the Inventory tab**
If candidates are present in the table from Step 2 (add a Stock Item via the `/stock/new` page first for the job's material if the list is empty, then reopen the modal), select at least one row's checkbox and click "Import N Item(s)".
Expected: modal closes, the new stock row appears in the Stock table with the correct material, length, quantity, and priority.
- [ ] **Step 4: Verify Custom Length tab add flow**
Reopen "Add Stock", click the "Custom Length" tab, fill in Shape, Size, Length, Quantity, Priority, click "Add Stock".
Expected: modal closes, the new custom-length row appears in the Stock table tagged with the "Custom" badge.
- [ ] **Step 5: Verify the Inventory tab is disabled when the job has no parts**
Create a new job with no parts yet, go to its Stock tab, click "Add Stock".
Expected: modal opens defaulting to the "Custom Length" tab; the "From Inventory" tab is visibly disabled and shows the "Add parts first to match against inventory" tooltip on hover; clicking it does nothing.
- [ ] **Step 6: Verify editing an inventory-sourced stock row**
On a job with an inventory-sourced stock row (from Step 3), click its Edit (pencil) button.
Expected: modal opens titled "Edit Stock" with no tab nav, showing the single inventory form (Shape/Size/Stock Length dropdown/Qty/Priority) prefilled with the row's current values. Change the quantity and click "Save Changes".
Expected: modal closes, the table row reflects the updated quantity.
- [ ] **Step 7: Verify editing a custom-length stock row**
On the custom-length row (from Step 4), click its Edit button.
Expected: modal opens titled "Edit Stock" with no tab nav, showing the custom form (Shape/Size/Length/Qty/Priority) prefilled. Change the priority and click "Save Changes".
Expected: modal closes, the table row reflects the updated priority.
- [ ] **Step 8: Verify Cancel discards changes in both Add and Edit mode**
Open "Add Stock", change a few fields on the Custom Length tab, click "Cancel". Reopen and confirm the fields are reset (not retaining the discarded values). Then click Edit on any row, change a field, click "Cancel", and confirm the table row is unchanged.
- [ ] **Step 9: Verify locked jobs still hide the button**
Lock a job (via the Results tab's "Lock Job" action, after running Optimize), return to the Stock tab.
Expected: "Add Stock" button is not rendered (matches pre-existing `@if (!job.IsLocked)` gating, unchanged by this plan) and Edit/Delete icons on stock rows are hidden.
- [ ] **Step 10: Take a screenshot of the modal in both tab states for the record, then stop the app**
Capture a screenshot of the modal on the "From Inventory" tab and one on "Custom Length" tab (Playwright `browser_take_screenshot`). Stop the `dotnet run` background process.
@@ -0,0 +1,74 @@
# Unified Add/Edit Stock Modal — Job Edit Page, Stock Tab
## Problem
The Stock tab on the Job Edit page (`CutList.Web/Components/Pages/Jobs/Edit.razor`) currently exposes three separate, inconsistent UI surfaces for adding/editing job stock:
1. **Import from Inventory** button → opens a full modal (`showImportModal`) with a bulk multi-select table (grouped by material, checkboxes, Select All/None, per-row qty/priority). Disabled entirely when the job has no parts.
2. **Add Custom Length** button → toggles an inline (non-modal) single-row form (`showCustomStockForm`) in the card body.
3. **Edit** (pencil icon on an existing stock row) → reuses one of two inline single-row forms depending on the item type: the bulk-import modal's sibling single form (`showStockForm`, inventory-sourced) or the custom form (`showCustomStockForm`), neither as a modal for the inventory case's add path (only reachable via Edit today).
This is confusing: two different buttons for adding, plus edit behavior that doesn't match either add flow.
## Goal
Consolidate all of this into a single **"Add Stock"** button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
## State Model
Replace the three separate visibility flags with:
- `showStockModal` (bool) — single modal visibility flag, replacing `showStockForm`, `showCustomStockForm`, and `showImportModal`.
- `stockModalTab` (enum: `Inventory`, `Custom`) — which tab is active. Only switchable in Add mode.
- `editingStock` (existing field) — `null` means Add mode; non-null means Edit mode, and fixes which tab is shown.
`newStock`, `importCandidates`, `stockErrorMessage`, `importErrorMessage`, `availableStockItems`, `stockSelectedShape`, `stockSelectedMaterialId` are all reused as-is from the current implementation.
## Header
The "Import from Inventory" and "Add Custom Length" buttons are replaced with a single **"Add Stock"** button. It is always enabled (not gated on `job.Parts.Count`). Clicking it:
- Sets `editingStock = null`.
- Opens the modal (`showStockModal = true`).
- Defaults `stockModalTab` to `Inventory` if `job.Parts.Count > 0`, otherwise `Custom`.
## Modal — Add Mode (`editingStock == null`)
Nav tabs shown: **From Inventory** | **Custom Length**.
- The **From Inventory** tab is disabled (with the existing tooltip copy, "Add parts first to match against inventory") when `job.Parts.Count == 0`. Its content is today's bulk-select table verbatim, reusing `importCandidates` and the existing `ShowImportModal` loading logic (grouped by material, Select All/None, per-row qty/priority inputs).
- The **Custom Length** tab content is today's single-row custom form verbatim (shape → size → length → qty → priority).
- Footer's primary button adapts to the active tab:
- Inventory tab: "Import N Item(s)", disabled when no candidates are selected — wired to `ImportSelectedStockAsync`.
- Custom tab: "Add Stock" — wired to `SaveCustomStockAsync`.
- "Cancel" button always present, closes the modal and resets `editingStock`, `newStock`, `importCandidates`, and error messages.
## Modal — Edit Mode (`editingStock != null`)
No tab nav is shown — a single fixed form matching the item's existing type:
- If `editingStock.IsCustomLength`: renders the same custom-form fields (shape → size → length → qty → priority), prefilled from the stock row — wired to `SaveCustomStockAsync`.
- Else: renders the same single-item inventory form fields (shape → size → stock-length dropdown → qty → priority), prefilled — wired to `SaveStockFromInventoryAsync`.
- Footer primary button: "Save Changes".
- "Cancel" button behaves as above.
`EditStock(stock)` sets `editingStock`, prefills `newStock` (as it does today), sets `stockModalTab` to match the item's type, and opens `showStockModal = true`.
## Method Reuse
The four existing handler methods keep their current logic unchanged — only the surrounding modal chrome (open/close state, which form renders) changes:
- `SaveStockFromInventoryAsync` — single inventory-sourced add/edit.
- `SaveCustomStockAsync` — single custom-length add/edit.
- `ImportSelectedStockAsync` — bulk inventory import (add only).
- `ShowImportModal` (the loading logic, not the modal-open flag itself) — populates `importCandidates` when the Inventory tab is active/opened in Add mode.
## Error Handling
Unchanged. Each tab/mode keeps its own existing inline `alert-danger` validation message (`stockErrorMessage` for the two single-item forms, `importErrorMessage` for the bulk table).
## Out of Scope
- No changes to the Parts tab or its modal.
- No changes to the underlying `JobService` methods or API surface.
- No change to validation rules or business logic — this is a UI consolidation only.