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); +}