From 9296bea0bc076201910f4eeb404f5995729ef73a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 31 Jul 2026 22:56:41 -0400 Subject: [PATCH] docs: add implementation plan for removing vendor/supplier data Ten-task plan ordered so the build stays green at each step: strip consumers first (StockItemService/DTOs/Controller, Stock/Edit.razor, Suppliers/Orders pages, Jobs/Edit.razor, CatalogService), delete the now-unused services, then remove the entities and migrate the schema last. Closes with CutList.Mcp cleanup and a manual smoke test. --- .../plans/2026-07-31-remove-vendor-data.md | 3253 +++++++++++++++++ 1 file changed, 3253 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-remove-vendor-data.md diff --git a/docs/superpowers/plans/2026-07-31-remove-vendor-data.md b/docs/superpowers/plans/2026-07-31-remove-vendor-data.md new file mode 100644 index 0000000..001fc2f --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-remove-vendor-data.md @@ -0,0 +1,3253 @@ +# Remove Vendor/Supplier Data 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:** Remove `Supplier`, `SupplierOffering`, and `PurchaseItem` (the Orders workflow) from CutList entirely, strip `StockTransaction` down to a vendor-free quantity log, and fix the bug that started this — an existing stock item's length field is hard-locked `readonly` in the UI. + +**Architecture:** This is a vertical removal across a Blazor Server + EF Core app. To keep the build green at every step, consumers are updated/deleted before the entities they depend on: UI pages and controllers that reference `Supplier`/`SupplierOffering`/`PurchaseItem` are stripped or deleted first (Tasks 1–6), then the entities, `ApplicationDbContext` config, and EF migration are removed last (Task 7), once nothing references them. `CutList.Mcp` and seed data cleanup follow (Tasks 8–9), and a final build + manual smoke test closes it out (Task 10). + +**Tech Stack:** .NET 8 Blazor Server (CutList.Web), EF Core + SQL Server, .NET 10 MCP stdio server (CutList.Mcp), Python (one-off catalog transform only). + +## Global Constraints + +- No automated test project exists in this solution (confirmed: `CutList.sln` has 4 projects, none named `*Tests*`). Verification is `dotnet build CutList.sln` after each task, plus manual smoke testing via the running app for UI-facing tasks (per project convention — see `docs/webapp-testing` skill for Playwright-driven checks if needed). +- Per global `CLAUDE.md`: apply EF Core migrations immediately after creating them (`dotnet ef database update`) — do not wait for user confirmation. +- Per global `CLAUDE.md`: MCP servers are published to `~/.claude/mcp//` via `dotnet publish -c Release -o`. +- The migration in Task 7 drops the `Suppliers`, `SupplierOfferings`, and `PurchaseItems` tables and columns outright — this is destructive to whatever's currently in the local dev DB (`Server=localhost\SQLEXPRESS;Database=CutListDb`). That's expected and already agreed in the design doc (`docs/superpowers/specs/2026-07-31-remove-vendor-data-design.md`). +- Design source of truth: `docs/superpowers/specs/2026-07-31-remove-vendor-data-design.md`. If any task here conflicts with it, the spec wins — stop and reconcile before proceeding. + +--- + +## File Structure + +**Modified:** +- `CutList.Web/Services/StockItemService.cs` — drop supplier/price params and cost-tracking methods +- `CutList.Web/DTOs/StockItemDtos.cs` — drop `StockPricingDto`, supplier/price fields +- `CutList.Web/Controllers/StockItemsController.cs` — drop `/offerings`, `/pricing` endpoints +- `CutList.Web/Components/Pages/Stock/Edit.razor` — editable length, strip supplier/offerings/pricing UI +- `CutList.Web/Components/Layout/NavMenu.razor` — drop Orders/Suppliers links +- `CutList.Web/Components/Pages/Home.razor` — drop Suppliers card, update copy +- `CutList.Web/Components/Pages/Jobs/Edit.razor` — "Add to Order List" → "Lock Job" +- `CutList.Web/Program.cs` — drop `SupplierService`/`PurchaseItemService` registrations +- `CutList.Web/DTOs/CatalogDtos.cs` — drop supplier/offering DTOs and fields +- `CutList.Web/Services/CatalogService.cs` — drop supplier import/export +- `CutList.Web/Data/Entities/StockTransaction.cs` — drop `SupplierId`/`UnitPrice`/`Supplier` nav +- `CutList.Web/Data/Entities/StockItem.cs` — drop `SupplierOfferings` nav +- `CutList.Web/Data/ApplicationDbContext.cs` — drop `Supplier`/`SupplierOffering`/`PurchaseItem` config and `DbSet`s +- `CutList.Web/Data/SeedData/oneals-catalog.json` — regenerated without vendor wrapper +- `CutList.Mcp/InventoryTools.cs` — drop supplier tools, add `add_stock` +- `CutList.Mcp/ApiClient.cs` — drop supplier/offering API methods and DTOs + +**Deleted:** +- `CutList.Web/Data/Entities/Supplier.cs` +- `CutList.Web/Data/Entities/SupplierOffering.cs` +- `CutList.Web/Data/Entities/PurchaseItem.cs` +- `CutList.Web/Services/SupplierService.cs` +- `CutList.Web/Services/PurchaseItemService.cs` +- `CutList.Web/Controllers/SuppliersController.cs` +- `CutList.Web/DTOs/SupplierDtos.cs` +- `CutList.Web/Components/Pages/Suppliers/Index.razor` +- `CutList.Web/Components/Pages/Suppliers/Edit.razor` +- `CutList.Web/Components/Pages/Orders/Index.razor` +- `CutList.Web/Components/Pages/Orders/Add.razor` + +**Created:** +- One new EF Core migration under `CutList.Web/Migrations/` (name: `RemoveVendorData`, generated by the `dotnet ef migrations add` command, not hand-written) + +--- + +### Task 1: StockItemService + StockItemDtos + StockItemsController — strip supplier/pricing + +**Files:** +- Modify: `CutList.Web/Services/StockItemService.cs` +- Modify: `CutList.Web/DTOs/StockItemDtos.cs` +- Modify: `CutList.Web/Controllers/StockItemsController.cs` + +**Interfaces:** +- Produces: `StockItemService.AddStockAsync(int stockItemId, int quantity, string? notes = null)` — Task 2 (`Stock/Edit.razor`) calls this. +- Produces: `StockItemService.GetTransactionHistoryAsync(int stockItemId, int? limit = null)` returning `StockTransaction` with `Job` included but no `Supplier` — Task 2 consumes this for the transaction history table. +- Removes: `StockItemService.GetAverageCostAsync`, `GetLastPurchasePriceAsync`, `StockPricingDto` — nothing later in this plan uses them. + +- [ ] **Step 1: Replace `StockItemService.cs`** + +Replace the entire file content with: + +```csharp +using CutList.Web.Data; +using CutList.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CutList.Web.Services; + +public class StockItemService +{ + private readonly IDbContextFactory _factory; + + public StockItemService(IDbContextFactory factory) + { + _factory = factory; + } + + public async Task> GetAllAsync(bool includeInactive = false) + { + await using var context = _factory.CreateDbContext(); + + var query = context.StockItems + .Include(s => s.Material) + .AsQueryable(); + + if (!includeInactive) + { + query = query.Where(s => s.IsActive); + } + + return await query + .OrderBy(s => s.Material.Shape) + .ThenBy(s => s.Material.Size) + .ThenBy(s => s.LengthInches) + .ToListAsync(); + } + + public async Task> GetByMaterialAsync(int materialId, bool includeInactive = false) + { + await using var context = _factory.CreateDbContext(); + + var query = context.StockItems + .Include(s => s.Material) + .Where(s => s.MaterialId == materialId); + + if (!includeInactive) + { + query = query.Where(s => s.IsActive); + } + + return await query + .OrderBy(s => s.LengthInches) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + await using var context = _factory.CreateDbContext(); + + return await context.StockItems + .Include(s => s.Material) + .FirstOrDefaultAsync(s => s.Id == id); + } + + public async Task CreateAsync(StockItem stockItem) + { + await using var context = _factory.CreateDbContext(); + + stockItem.CreatedAt = DateTime.UtcNow; + context.StockItems.Add(stockItem); + await context.SaveChangesAsync(); + return stockItem; + } + + public async Task UpdateAsync(StockItem stockItem) + { + await using var context = _factory.CreateDbContext(); + + stockItem.UpdatedAt = DateTime.UtcNow; + context.StockItems.Update(stockItem); + await context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(id); + if (stockItem != null) + { + stockItem.IsActive = false; + stockItem.UpdatedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + } + } + + public async Task ExistsAsync(int materialId, decimal lengthInches, int? excludeId = null) + { + await using var context = _factory.CreateDbContext(); + + var query = context.StockItems.Where(s => + s.MaterialId == materialId && + s.LengthInches == lengthInches && + s.IsActive); + + if (excludeId.HasValue) + { + query = query.Where(s => s.Id != excludeId.Value); + } + + return await query.AnyAsync(); + } + + // Stock transaction methods + public async Task AddStockAsync(int stockItemId, int quantity, string? notes = null) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = quantity, + Type = StockTransactionType.Received, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand += quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + context.StockTransactions.Add(transaction); + await context.SaveChangesAsync(); + + return transaction; + } + + public async Task UseStockAsync(int stockItemId, int quantity, int? jobId = null, string? notes = null) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = -quantity, + Type = StockTransactionType.Used, + JobId = jobId, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand -= quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + context.StockTransactions.Add(transaction); + await context.SaveChangesAsync(); + + return transaction; + } + + public async Task AdjustStockAsync(int stockItemId, int newQuantity, string? notes = null) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var difference = newQuantity - stockItem.QuantityOnHand; + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = difference, + Type = StockTransactionType.Adjustment, + Notes = notes ?? "Manual adjustment", + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand = newQuantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + context.StockTransactions.Add(transaction); + await context.SaveChangesAsync(); + + return transaction; + } + + public async Task ScrapStockAsync(int stockItemId, int quantity, string? notes = null) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = -quantity, + Type = StockTransactionType.Scrapped, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand -= quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + context.StockTransactions.Add(transaction); + await context.SaveChangesAsync(); + + return transaction; + } + + public async Task> GetTransactionHistoryAsync(int stockItemId, int? limit = null) + { + await using var context = _factory.CreateDbContext(); + + var query = context.StockTransactions + .Include(t => t.Job) + .Where(t => t.StockItemId == stockItemId) + .OrderByDescending(t => t.CreatedAt) + .AsQueryable(); + + if (limit.HasValue) + { + query = query.Take(limit.Value); + } + + return await query.ToListAsync(); + } + + public async Task RecalculateQuantityAsync(int stockItemId) + { + await using var context = _factory.CreateDbContext(); + + var stockItem = await context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var calculatedQuantity = await context.StockTransactions + .Where(t => t.StockItemId == stockItemId) + .SumAsync(t => t.Quantity); + + if (stockItem.QuantityOnHand != calculatedQuantity) + { + stockItem.QuantityOnHand = calculatedQuantity; + stockItem.UpdatedAt = DateTime.UtcNow; + await context.SaveChangesAsync(); + } + + return calculatedQuantity; + } +} +``` + +- [ ] **Step 2: Replace `StockItemDtos.cs`** + +Replace the entire file content with: + +```csharp +namespace CutList.Web.DTOs; + +public class StockItemDto +{ + public int Id { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public decimal LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public string? Name { get; set; } + public int QuantityOnHand { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class CreateStockItemDto +{ + public int MaterialId { get; set; } + public string Length { get; set; } = string.Empty; + public string? Name { get; set; } + public int QuantityOnHand { get; set; } + public string? Notes { get; set; } +} + +public class UpdateStockItemDto +{ + public string? Length { get; set; } + public string? Name { get; set; } + public string? Notes { get; set; } +} + +public class StockTransactionDto +{ + public int Id { get; set; } + public int StockItemId { get; set; } + public int Quantity { get; set; } + public string Type { get; set; } = string.Empty; + public int? JobId { get; set; } + public string? JobNumber { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class AddStockDto +{ + public int Quantity { get; set; } + public string? Notes { get; set; } +} + +public class UseStockDto +{ + public int Quantity { get; set; } + public int? JobId { get; set; } + public string? Notes { get; set; } +} + +public class AdjustStockDto +{ + public int NewQuantity { get; set; } + public string? Notes { get; set; } +} + +public class ScrapStockDto +{ + public int Quantity { get; set; } + public string? Notes { get; set; } +} +``` + +- [ ] **Step 3: Replace `StockItemsController.cs`** + +Replace the entire file content with: + +```csharp +using CutList.Core.Formatting; +using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/stock-items")] +public class StockItemsController : ControllerBase +{ + private readonly StockItemService _stockItemService; + + public StockItemsController(StockItemService stockItemService) + { + _stockItemService = stockItemService; + } + + [HttpGet] + public async Task>> GetAll( + [FromQuery] bool includeInactive = false, + [FromQuery] int? materialId = null) + { + List items; + if (materialId.HasValue) + items = await _stockItemService.GetByMaterialAsync(materialId.Value, includeInactive); + else + items = await _stockItemService.GetAllAsync(includeInactive); + + return Ok(items.Select(MapToDto).ToList()); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + return Ok(MapToDto(item)); + } + + [HttpPost] + public async Task> Create(CreateStockItemDto dto) + { + double lengthInches; + try + { + lengthInches = ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + + var exists = await _stockItemService.ExistsAsync(dto.MaterialId, (decimal)lengthInches); + if (exists) + return Conflict("A stock item with this material and length already exists"); + + var stockItem = new StockItem + { + MaterialId = dto.MaterialId, + LengthInches = (decimal)lengthInches, + Name = dto.Name, + QuantityOnHand = dto.QuantityOnHand, + Notes = dto.Notes + }; + + await _stockItemService.CreateAsync(stockItem); + + // Reload with includes + var created = await _stockItemService.GetByIdAsync(stockItem.Id); + return CreatedAtAction(nameof(GetById), new { id = stockItem.Id }, MapToDto(created!)); + } + + [HttpPut("{id}")] + public async Task> Update(int id, UpdateStockItemDto dto) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + if (dto.Length != null) + { + try + { + item.LengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + } + + if (dto.Name != null) item.Name = dto.Name; + if (dto.Notes != null) item.Notes = dto.Notes; + + await _stockItemService.UpdateAsync(item); + return Ok(MapToDto(item)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + await _stockItemService.DeleteAsync(id); + return NoContent(); + } + + [HttpGet("by-material/{materialId}")] + public async Task>> GetByMaterial(int materialId) + { + var items = await _stockItemService.GetByMaterialAsync(materialId); + return Ok(items.Select(MapToDto).ToList()); + } + + [HttpGet("{id}/transactions")] + public async Task>> GetTransactions(int id, [FromQuery] int? limit = null) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + var transactions = await _stockItemService.GetTransactionHistoryAsync(id, limit); + return Ok(transactions.Select(MapTransactionToDto).ToList()); + } + + [HttpPost("{id}/receive")] + public async Task> ReceiveStock(int id, AddStockDto dto) + { + try + { + var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/use")] + public async Task> UseStock(int id, UseStockDto dto) + { + try + { + var transaction = await _stockItemService.UseStockAsync(id, dto.Quantity, dto.JobId, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/adjust")] + public async Task> AdjustStock(int id, AdjustStockDto dto) + { + try + { + var transaction = await _stockItemService.AdjustStockAsync(id, dto.NewQuantity, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/scrap")] + public async Task> ScrapStock(int id, ScrapStockDto dto) + { + try + { + var transaction = await _stockItemService.ScrapStockAsync(id, dto.Quantity, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/recalculate")] + public async Task> RecalculateStock(int id) + { + try + { + var newQuantity = await _stockItemService.RecalculateQuantityAsync(id); + return Ok(new { QuantityOnHand = newQuantity }); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + private static StockItemDto MapToDto(StockItem s) => new() + { + Id = s.Id, + MaterialId = s.MaterialId, + MaterialName = s.Material?.DisplayName ?? string.Empty, + LengthInches = s.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), + Name = s.Name, + QuantityOnHand = s.QuantityOnHand, + Notes = s.Notes, + IsActive = s.IsActive + }; + + private static StockTransactionDto MapTransactionToDto(StockTransaction t) => new() + { + Id = t.Id, + StockItemId = t.StockItemId, + Quantity = t.Quantity, + Type = t.Type.ToString(), + JobId = t.JobId, + JobNumber = t.Job?.JobNumber, + Notes = t.Notes, + CreatedAt = t.CreatedAt + }; +} +``` + +- [ ] **Step 4: Build to verify no compile errors introduced yet** + +Run: `dotnet build CutList.sln` +Expected: Build FAILS — `Stock/Edit.razor`, `Orders/*.razor`, `CatalogService.cs` etc. still call the old `AddStockAsync`/`GetByIdAsync` overloads and reference `StockPricingDto`. That's expected; Task 2 onward fixes each consumer. Confirm the *only* errors are in those not-yet-updated files (`Stock/Edit.razor`, `Orders/Index.razor`, `Orders/Add.razor`), not in `StockItemsController.cs`/`StockItemService.cs`/`StockItemDtos.cs` themselves. + +- [ ] **Step 5: Commit** + +```bash +git add CutList.Web/Services/StockItemService.cs CutList.Web/DTOs/StockItemDtos.cs CutList.Web/Controllers/StockItemsController.cs +git commit -m "refactor: strip supplier/pricing from StockItemService and stock-items API" +``` + +--- + +### Task 2: Stock/Edit.razor — editable length + strip supplier/offerings/pricing UI + +**Files:** +- Modify: `CutList.Web/Components/Pages/Stock/Edit.razor` + +**Interfaces:** +- Consumes: `StockItemService.AddStockAsync(int, int, string?)`, `AdjustStockAsync`, `ScrapStockAsync`, `GetTransactionHistoryAsync` from Task 1. +- No longer consumes `SupplierService` (any method) — this task removes the injection entirely. + +- [ ] **Step 1: Replace `Stock/Edit.razor`** + +This fixes the original reported bug (length field locked `readonly` outside creation) and removes the Supplier Offerings card, the Supplier/Unit Price fields on the transaction form, and the Supplier/Price columns in transaction history. + +Replace the entire file content with: + +```razor +@page "/stock/new" +@page "/stock/{Id:int}" +@inject StockItemService StockItemService +@inject MaterialService MaterialService +@inject NavigationManager Navigation +@using CutList.Core.Formatting + +@(IsNew ? "Add Stock Item" : "Edit Stock Item") + +

@(IsNew ? "Add Stock Item" : $"{stockItem.Material?.DisplayName} - {ArchUnits.FormatFromInches((double)stockItem.LengthInches)}")

+ +@if (loading) +{ +

Loading...

+} +else +{ +
+
+
+
+
Stock Item Details
+
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + +
+ + @(IsNew ? "Cancel" : "Back to List") +
+
+
+
+
+ + @if (!IsNew) + { +
+
+
+
+ Inventory + @stockItem.QuantityOnHand on hand +
+ +
+
+ @if (showStockForm) + { +
+
Stock Transaction
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(stockFormErrorMessage)) + { +
@stockFormErrorMessage
+ } +
+ + +
+
+ } + + @if (transactions.Count == 0) + { +

No transaction history yet.

+ } + else + { + + + + + + + + + + + @foreach (var txn in transactions) + { + + + + + + + } + +
DateTypeQtyNotes
@txn.CreatedAt.ToLocalTime().ToString("MM/dd/yy HH:mm") + @txn.Type + + @(txn.Quantity >= 0 ? "+" : "")@txn.Quantity + @(txn.Notes ?? "-")
+ } +
+
+
+ } +
+} + +@code { + [Parameter] + public int? Id { get; set; } + + private StockItem stockItem = new(); + private List materials = new(); + private List transactions = new(); + private bool loading = true; + private bool saving; + private string? errorMessage; + + // Stock transaction form + private bool showStockForm; + private bool savingStockTransaction; + private string stockTransactionType = "add"; + private int stockQuantity; + private string? stockNotes; + private string? stockFormErrorMessage; + + private bool IsNew => !Id.HasValue; + + protected override async Task OnInitializedAsync() + { + materials = await MaterialService.GetAllAsync(); + + if (Id.HasValue) + { + var existing = await StockItemService.GetByIdAsync(Id.Value); + if (existing == null) + { + Navigation.NavigateTo("stock"); + return; + } + stockItem = existing; + transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20); + } + loading = false; + } + + private string GetTransactionBadgeClass(StockTransactionType type) => type switch + { + StockTransactionType.Received => "bg-success", + StockTransactionType.Used => "bg-primary", + StockTransactionType.Adjustment => "bg-warning text-dark", + StockTransactionType.Scrapped => "bg-danger", + StockTransactionType.Returned => "bg-info", + _ => "bg-secondary" + }; + + private void ShowStockForm() + { + stockTransactionType = "add"; + stockQuantity = 0; + stockNotes = null; + stockFormErrorMessage = null; + showStockForm = true; + } + + private void CancelStockForm() + { + showStockForm = false; + stockFormErrorMessage = null; + } + + private async Task SaveStockTransactionAsync() + { + stockFormErrorMessage = null; + savingStockTransaction = true; + + try + { + if (stockQuantity <= 0 && stockTransactionType != "adjust") + { + stockFormErrorMessage = "Quantity must be greater than zero"; + return; + } + + if (stockTransactionType == "adjust" && stockQuantity < 0) + { + stockFormErrorMessage = "Quantity cannot be negative"; + return; + } + + switch (stockTransactionType) + { + case "add": + await StockItemService.AddStockAsync(Id!.Value, stockQuantity, stockNotes); + break; + case "adjust": + await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes); + break; + case "scrap": + await StockItemService.ScrapStockAsync(Id!.Value, stockQuantity, stockNotes); + break; + } + + // Refresh + var updated = await StockItemService.GetByIdAsync(Id!.Value); + if (updated != null) + { + stockItem = updated; + } + transactions = await StockItemService.GetTransactionHistoryAsync(Id!.Value, 20); + showStockForm = false; + } + finally + { + savingStockTransaction = false; + } + } + + private async Task SaveStockItemAsync() + { + errorMessage = null; + saving = true; + + try + { + if (stockItem.MaterialId == 0) + { + errorMessage = "Please select a material"; + return; + } + + if (stockItem.LengthInches <= 0) + { + errorMessage = "Length must be greater than zero"; + return; + } + + var exists = await StockItemService.ExistsAsync( + stockItem.MaterialId, + stockItem.LengthInches, + IsNew ? null : stockItem.Id); + + if (exists) + { + errorMessage = "A stock item with this material and length already exists"; + return; + } + + if (IsNew) + { + var created = await StockItemService.CreateAsync(stockItem); + Navigation.NavigateTo($"stock/{created.Id}"); + } + else + { + await StockItemService.UpdateAsync(stockItem); + } + } + finally + { + saving = false; + } + } +} +``` + +Note: `Material`, `StockItem`, `StockTransaction`, and `StockTransactionType` resolve without an explicit `@using` — `CutList.Web.Data.Entities` and `CutList.Web.Services` are both global usings declared in `CutList.Web/Components/_Imports.razor`, which applies to every `.razor` file under `Components/`. + +- [ ] **Step 2: Build to verify this file compiles clean** + +Run: `dotnet build CutList.sln` +Expected: No errors originating from `Stock/Edit.razor`. Errors may still remain in `Orders/*.razor`, `CatalogService.cs` (fixed in later tasks). + +- [ ] **Step 3: Commit** + +```bash +git add CutList.Web/Components/Pages/Stock/Edit.razor +git commit -m "fix: make stock item length editable and drop vendor UI from Stock Edit page" +``` + +--- + +### Task 3: Delete Suppliers & Orders pages; update NavMenu.razor + Home.razor + +**Files:** +- Delete: `CutList.Web/Components/Pages/Suppliers/Index.razor` +- Delete: `CutList.Web/Components/Pages/Suppliers/Edit.razor` +- Delete: `CutList.Web/Components/Pages/Orders/Index.razor` +- Delete: `CutList.Web/Components/Pages/Orders/Add.razor` +- Modify: `CutList.Web/Components/Layout/NavMenu.razor` +- Modify: `CutList.Web/Components/Pages/Home.razor` + +**Interfaces:** +- None — this task only removes UI entry points. `SupplierService`/`PurchaseItemService` are still registered at this point (deleted in Task 5) so the build stays green even though these two services now have zero UI consumers left in `CutList.Web` other than `Jobs/Edit.razor` (fixed in Task 4) and `CatalogService`/`StockItemsController` (already fixed in Tasks 1 and 6). + +- [ ] **Step 1: Delete the four Razor files** + +```bash +git rm CutList.Web/Components/Pages/Suppliers/Index.razor +git rm CutList.Web/Components/Pages/Suppliers/Edit.razor +git rm CutList.Web/Components/Pages/Orders/Index.razor +git rm CutList.Web/Components/Pages/Orders/Add.razor +``` + +- [ ] **Step 2: Replace `NavMenu.razor`** + +Replace the entire file content with: + +```razor + + + + + +``` + +- [ ] **Step 3: Replace `Home.razor`** + +Replace the entire file content with: + +```razor +@page "/" + +CutList - Home + +

CutList

+ +

1D Bin Packing Optimization for Material Cutting

+ +
+
+
+
+
Jobs
+

Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.

+ Go to Jobs +
+
+
+
+
+
+
Materials
+

Manage material types (tube, bar, angle, etc.) with their shapes and sizes.

+ Manage Materials +
+
+
+
+
+
+
Cutting Tools
+

Configure cutting tools with their kerf widths for accurate waste calculations.

+ Manage Tools +
+
+
+
+ +
+ +

How It Works

+
    +
  1. Set up materials - Define the shapes and sizes of materials you work with
  2. +
  3. Add stock - Record which stock lengths you have on hand
  4. +
  5. Create a job - Add the parts you need to cut with their lengths and quantities
  6. +
  7. Add stock bins - Specify which stock lengths to cut from
  8. +
  9. Optimize - Run the optimizer to find the best cutting pattern
  10. +
  11. Print report - Generate a printable cut list to take to the shop
  12. +
+``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build CutList.sln` +Expected: The four deleted files no longer appear in errors. Remaining errors (if any) should only be in `Jobs/Edit.razor` and `CatalogService.cs` (fixed in Tasks 4 and 6). + +- [ ] **Step 5: Commit** + +```bash +git add -A CutList.Web/Components/Pages/Suppliers CutList.Web/Components/Pages/Orders CutList.Web/Components/Layout/NavMenu.razor CutList.Web/Components/Pages/Home.razor +git commit -m "feat: remove Suppliers and Orders pages and nav entries" +``` + +--- + +### Task 4: Jobs/Edit.razor — replace "Add to Order List" with "Lock Job" + +**Files:** +- Modify: `CutList.Web/Components/Pages/Jobs/Edit.razor` + +**Interfaces:** +- Consumes: `JobService.LockAsync(int id)` / `UnlockAsync(int id)` — already exist, unchanged, from `CutList.Web/Services/JobService.cs`. +- No longer consumes `PurchaseItemService` (any method). + +- [ ] **Step 1: Drop the `PurchaseItemService` injection** + +In `CutList.Web/Components/Pages/Jobs/Edit.razor`, find: + +```razor +@inject CutListPackingService PackingService +@inject PurchaseItemService PurchaseItemService +@inject NavigationManager Navigation +``` + +Replace with: + +```razor +@inject CutListPackingService PackingService +@inject NavigationManager Navigation +``` + +- [ ] **Step 2: Rename the tracking fields** + +Find: + +```csharp + private bool addingToOrderList; + private bool addedToOrderList; +``` + +Replace with: + +```csharp + private bool lockingJob; + private bool jobLocked; +``` + +- [ ] **Step 3: Update `LoadSavedResultsAsync`** + +Find: + +```csharp + summary = PackingService.GetSummary(packResult); + addedToOrderList = job.IsLocked; + } + } + catch + { + // Invalid JSON — treat as no results +``` + +Replace with: + +```csharp + summary = PackingService.GetSummary(packResult); + jobLocked = job.IsLocked; + } + } + catch + { + // Invalid JSON — treat as no results +``` + +- [ ] **Step 4: Replace `AddToOrderList` with `LockJob`** + +Find: + +```csharp + // 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(); + 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; + } + } +``` + +Replace with: + +```csharp + // Refresh job to get updated OptimizedAt + job = (await JobService.GetByIdAsync(Id!.Value))!; + jobLocked = job.IsLocked; + } + finally + { + optimizing = false; + } + } + + private async Task LockJob() + { + lockingJob = true; + try + { + await JobService.LockAsync(Id!.Value); + job = (await JobService.GetByIdAsync(Id!.Value))!; + jobLocked = true; + } + finally + { + lockingJob = false; + } + } +``` + +- [ ] **Step 5: Update the Purchase List card markup** + +Find: + +```razor + +