diff --git a/docs/superpowers/plans/2026-08-01-unified-add-stock-modal.md b/docs/superpowers/plans/2026-08-01-unified-add-stock-modal.md new file mode 100644 index 0000000..1e0e9e6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-unified-add-stock-modal.md @@ -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 availableStockItems = new(); + + // Import modal + private bool showImportModal; + private bool loadingImport; + private List 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 availableStockItems = new(); + private bool loadingImport; + private List 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 => + { +
+
+
Stock for This Job
+ @if (!job.IsLocked) + { +
+ + +
+ } +
+
+ @if (showStockForm) + { + @RenderStockFromInventoryForm() + } + else if (showCustomStockForm) + { + @RenderCustomStockForm() + } + + @if (job.Stock.Count == 0) + { +
+

No stock configured for this job.

+

Add stock from your inventory or define custom lengths.

+

If no stock is selected, the optimizer will use all available stock for the materials in your parts list.

+
+ } + else + { + @RenderStockTable() + } +
+
+ }; +``` + +Replace it with: + +```csharp + // Stock tab + private RenderFragment RenderStockTab() => __builder => + { +
+
+
Stock for This Job
+ @if (!job.IsLocked) + { + + } +
+
+ @if (job.Stock.Count == 0) + { +
+

No stock configured for this job.

+

Add stock from your inventory or define custom lengths.

+

If no stock is selected, the optimizer will use all available stock for the materials in your parts list.

+
+ } + else + { + @RenderStockTable() + } +
+
+ }; +``` + +- [ ] **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 => + { +
+
@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")
+ ... +
+ }; + + private RenderFragment RenderCustomStockForm() => __builder => + { +
+
@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")
+ ... +
+ }; +``` + +(Their field content is reused, without the wrapping `
`/`
`/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) +{ + +} +``` + +(the full existing block — everything between `@* Import Stock Modal *@` and its closing `}`) + +Replace it with: + +```razor +@* Add/Edit Stock Modal *@ +@if (showStockModal) +{ + +} +``` + +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.