Files
CutList/CutList.Web/Components/Pages/Orders/Index.razor
AJ Isaacs 2586f99c63 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 <noreply@anthropic.com>
2026-02-07 23:03:17 -05:00

292 lines
11 KiB
Plaintext

@page "/orders"
@inject PurchaseItemService PurchaseItemService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Orders</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>To Be Ordered</h1>
<a href="orders/add" class="btn btn-primary">Add Item</a>
</div>
<p class="text-muted mb-4">
Track material that needs to be ordered from suppliers. Items are added manually or automatically from job optimization results.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeTab == "pending" ? "active" : "")" @onclick='() => SetTab("pending")'>
Pending
@if (pendingCount > 0)
{
<span class="badge bg-warning text-dark ms-1">@pendingCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "ordered" ? "active" : "")" @onclick='() => SetTab("ordered")'>
Ordered
@if (orderedCount > 0)
{
<span class="badge bg-primary ms-1">@orderedCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "all" ? "active" : "")" @onclick='() => SetTab("all")'>All</button>
</li>
</ul>
@if (tabItems.Count == 0)
{
<div class="alert alert-info">
@if (activeTab == "pending")
{
<span>No pending items. <a href="orders/add">Add an item</a> or use "Add to Order List" from a job's results page.</span>
}
else if (activeTab == "ordered")
{
<span>No ordered items.</span>
}
else
{
<span>No order items found. <a href="orders/add">Add your first item</a>.</span>
}
</div>
}
else
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredItems.Count == 0)
{
<div class="alert alert-warning">
No items match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Supplier</th>
<th>Job</th>
<th>Status</th>
<th>Notes</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in pagedItems)
{
<tr>
<td>@item.StockItem.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)</td>
<td>@item.Quantity</td>
<td>
<select class="form-select form-select-sm" style="min-width: 140px;"
value="@(item.SupplierId?.ToString() ?? "")"
@onchange="(e) => OnSupplierChanged(item, e)">
<option value="">-- Select --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</td>
<td>
@if (item.Job != null)
{
<a href="jobs/@item.Job.Id">@item.Job.DisplayName</a>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
<span class="badge @GetStatusBadgeClass(item.Status)">@item.Status</span>
</td>
<td>
<span class="text-muted small">@(item.Notes ?? "-")</span>
</td>
<td>
<div class="d-flex gap-1">
@if (item.Status == PurchaseItemStatus.Pending)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => MarkOrdered(item)" title="Mark Ordered">
<i class="bi bi-truck"></i>
</button>
}
@if (item.Status == PurchaseItemStatus.Ordered)
{
<button class="btn btn-sm btn-outline-success" @onclick="() => MarkReceived(item)" title="Mark Received">
<i class="bi bi-check-lg"></i>
</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredItems.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Order Item"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<PurchaseItem> allItems = new();
private List<Supplier> 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<PurchaseItem> tabItems => activeTab switch
{
"pending" => allItems.Where(i => i.Status == PurchaseItemStatus.Pending).ToList(),
"ordered" => allItems.Where(i => i.Status == PurchaseItemStatus.Ordered).ToList(),
_ => allItems
};
private List<PurchaseItem> 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<string> availableGrades => tabItems
.Select(i => i.StockItem.Material.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<PurchaseItem> 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);
}