From 2586f99c637dc4ad816504ac4b2a7f36195bdcad Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Feb 2026 23:03:17 -0500 Subject: [PATCH] feat: Add purchase order flow with Orders pages Add "Add to Order List" button on Results page that creates PurchaseItems from optimization results and locks the job. Add Orders Index page with tabbed view (Pending/Ordered/All), supplier assignment, status transitions, and MaterialFilter. Add manual order item creation page. Add Orders link to navigation menu. Co-Authored-By: Claude Opus 4.6 --- CutList.Web/Components/Layout/NavMenu.razor | 5 + .../Components/Layout/NavMenu.razor.css | 4 + .../Components/Pages/Jobs/Results.razor | 90 +++++- CutList.Web/Components/Pages/Orders/Add.razor | 151 +++++++++ .../Components/Pages/Orders/Index.razor | 291 ++++++++++++++++++ 5 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 CutList.Web/Components/Pages/Orders/Add.razor create mode 100644 CutList.Web/Components/Pages/Orders/Index.razor diff --git a/CutList.Web/Components/Layout/NavMenu.razor b/CutList.Web/Components/Layout/NavMenu.razor index 5adf4b9..87759a3 100644 --- a/CutList.Web/Components/Layout/NavMenu.razor +++ b/CutList.Web/Components/Layout/NavMenu.razor @@ -28,6 +28,11 @@ Stock Items + @@ -195,6 +225,9 @@ else private MultiMaterialPackingSummary? summary; private bool loading = true; + private bool addingToOrderList; + private bool addedToOrderList; + private bool CanOptimize => job != null && job.Parts.Count > 0 && job.CuttingToolId != null; @@ -209,6 +242,7 @@ else // Pass job stock if configured, otherwise packing service uses all available stock packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null); summary = PackingService.GetSummary(packResult); + addedToOrderList = job.IsLocked; } loading = false; @@ -250,6 +284,58 @@ else }; + 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, + Status = PurchaseItemStatus.Pending + }); + } + } + } + + if (purchaseItems.Count > 0) + { + await PurchaseItemService.CreateBulkAsync(purchaseItems); + } + + await JobService.LockAsync(Id); + job = await JobService.GetByIdAsync(Id); + addedToOrderList = true; + } + finally + { + addingToOrderList = false; + } + } + private async Task PrintReport() { var filename = $"CutList - {job!.Name} - {DateTime.Now:yyyy-MM-dd}"; diff --git a/CutList.Web/Components/Pages/Orders/Add.razor b/CutList.Web/Components/Pages/Orders/Add.razor new file mode 100644 index 0000000..72c9d4d --- /dev/null +++ b/CutList.Web/Components/Pages/Orders/Add.razor @@ -0,0 +1,151 @@ +@page "/orders/add" +@inject PurchaseItemService PurchaseItemService +@inject StockItemService StockItemService +@inject SupplierService SupplierService +@inject JobService JobService +@inject NavigationManager Navigation +@using CutList.Core.Formatting +@using CutList.Web.Data.Entities + +Add Order Item + +

Add Order Item

+ +@if (loading) +{ +

Loading...

+} +else +{ +
+
+
+
+
Order Item Details
+
+
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + +
+ + Cancel +
+
+
+
+
+
+} + +@code { + private PurchaseItem item = new() { Quantity = 1 }; + private List stockItems = new(); + private Dictionary> stockItemGroups = new(); + private List suppliers = new(); + private List jobs = new(); + private bool loading = true; + private bool saving; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + stockItems = await StockItemService.GetAllAsync(); + suppliers = await SupplierService.GetAllAsync(); + jobs = await JobService.GetAllAsync(); + + stockItemGroups = stockItems + .GroupBy(s => s.Material.Shape.GetDisplayName()) + .OrderBy(g => g.Key) + .ToDictionary(g => g.Key, g => g.OrderBy(s => s.Material.Size).ThenBy(s => s.LengthInches).ToList()); + + loading = false; + } + + private async Task SaveAsync() + { + errorMessage = null; + saving = true; + + try + { + if (item.StockItemId == 0) + { + errorMessage = "Please select a stock item"; + return; + } + + if (item.Quantity <= 0) + { + errorMessage = "Quantity must be at least 1"; + return; + } + + await PurchaseItemService.CreateAsync(item); + Navigation.NavigateTo("orders"); + } + finally + { + saving = false; + } + } +} diff --git a/CutList.Web/Components/Pages/Orders/Index.razor b/CutList.Web/Components/Pages/Orders/Index.razor new file mode 100644 index 0000000..7c30880 --- /dev/null +++ b/CutList.Web/Components/Pages/Orders/Index.razor @@ -0,0 +1,291 @@ +@page "/orders" +@inject PurchaseItemService PurchaseItemService +@inject SupplierService SupplierService +@inject NavigationManager Navigation +@using CutList.Core.Formatting +@using CutList.Web.Data.Entities + +Orders + +
+

To Be Ordered

+ Add Item +
+ +

+ Track material that needs to be ordered from suppliers. Items are added manually or automatically from job optimization results. +

+ +@if (loading) +{ +

Loading...

+} +else +{ + + + @if (tabItems.Count == 0) + { +
+ @if (activeTab == "pending") + { + No pending items. Add an item or use "Add to Order List" from a job's results page. + } + else if (activeTab == "ordered") + { + No ordered items. + } + else + { + No order items found. Add your first item. + } +
+ } + else + { + + + @if (filteredItems.Count == 0) + { +
+ No items match your filters. +
+ } + else + { + + + + + + + + + + + + + + + @foreach (var item in pagedItems) + { + + + + + + + + + + + } + +
MaterialLengthQtySupplierJobStatusNotesActions
@item.StockItem.Material.DisplayName@ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)@item.Quantity + + + @if (item.Job != null) + { + @item.Job.DisplayName + } + else + { + - + } + + @item.Status + + @(item.Notes ?? "-") + +
+ @if (item.Status == PurchaseItemStatus.Pending) + { + + } + @if (item.Status == PurchaseItemStatus.Ordered) + { + + } + +
+
+ + + } + } +} + + + +@code { + private List allItems = new(); + private List suppliers = new(); + private bool loading = true; + private string activeTab = "pending"; + private int currentPage = 1; + private int pageSize = 25; + private ConfirmDialog deleteDialog = null!; + private PurchaseItem? itemToDelete; + private string deleteMessage = ""; + private MaterialFilterState filterState = new(); + + private int pendingCount => allItems.Count(i => i.Status == PurchaseItemStatus.Pending); + private int orderedCount => allItems.Count(i => i.Status == PurchaseItemStatus.Ordered); + + private List tabItems => activeTab switch + { + "pending" => allItems.Where(i => i.Status == PurchaseItemStatus.Pending).ToList(), + "ordered" => allItems.Where(i => i.Status == PurchaseItemStatus.Ordered).ToList(), + _ => allItems + }; + + private List filteredItems => tabItems.Where(i => + { + var m = i.StockItem.Material; + if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value) + return false; + if (filterState.Type.HasValue && m.Type != filterState.Type.Value) + return false; + if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade) + return false; + if (!string.IsNullOrWhiteSpace(filterState.SearchText)) + { + var search = filterState.SearchText.Trim(); + if (!Contains(m.Size, search) + && !Contains(m.Grade, search) + && !Contains(m.Shape.GetDisplayName(), search) + && !Contains(i.Notes, search) + && !Contains(i.Job?.DisplayName, search) + && !Contains(i.Supplier?.Name, search)) + return false; + } + return true; + }).ToList(); + + private IEnumerable availableGrades => tabItems + .Select(i => i.StockItem.Material.Grade) + .Where(g => !string.IsNullOrEmpty(g)) + .Distinct() + .OrderBy(g => g)!; + + private IEnumerable pagedItems => filteredItems + .Skip((currentPage - 1) * pageSize) + .Take(pageSize); + + protected override async Task OnInitializedAsync() + { + allItems = await PurchaseItemService.GetAllAsync(); + suppliers = await SupplierService.GetAllAsync(); + loading = false; + } + + private void SetTab(string tab) + { + activeTab = tab; + currentPage = 1; + filterState = new(); + } + + private void OnFilterChanged(MaterialFilterState state) + { + filterState = state; + currentPage = 1; + } + + private async Task OnSupplierChanged(PurchaseItem item, ChangeEventArgs e) + { + int? supplierId = int.TryParse(e.Value?.ToString(), out var id) && id > 0 ? id : null; + await PurchaseItemService.UpdateSupplierAsync(item.Id, supplierId); + item.SupplierId = supplierId; + item.Supplier = supplierId.HasValue ? suppliers.FirstOrDefault(s => s.Id == supplierId.Value) : null; + } + + private async Task MarkOrdered(PurchaseItem item) + { + await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Ordered); + item.Status = PurchaseItemStatus.Ordered; + } + + private async Task MarkReceived(PurchaseItem item) + { + await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Received); + allItems = await PurchaseItemService.GetAllAsync(); + + var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize); + if (currentPage > totalPages && totalPages > 0) + currentPage = totalPages; + } + + private void ConfirmDelete(PurchaseItem item) + { + itemToDelete = item; + deleteMessage = $"Are you sure you want to delete this order item ({item.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)} x{item.Quantity})?"; + deleteDialog.Show(); + } + + private async Task DeleteConfirmed() + { + if (itemToDelete != null) + { + await PurchaseItemService.DeleteAsync(itemToDelete.Id); + allItems = await PurchaseItemService.GetAllAsync(); + + var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize); + if (currentPage > totalPages && totalPages > 0) + currentPage = totalPages; + } + } + + private void OnPageChanged(int page) => currentPage = page; + + private static string GetStatusBadgeClass(PurchaseItemStatus status) => status switch + { + PurchaseItemStatus.Pending => "bg-warning text-dark", + PurchaseItemStatus.Ordered => "bg-primary", + PurchaseItemStatus.Received => "bg-success", + _ => "bg-secondary" + }; + + private static bool Contains(string? value, string search) => + value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase); +}