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>
This commit is contained in:
@@ -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
|
||||
|
||||
<PageTitle>Add Order Item</PageTitle>
|
||||
|
||||
<h1>Add Order Item</h1>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Order Item Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="item" OnValidSubmit="SaveAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Stock Item</label>
|
||||
<select class="form-select" @bind="item.StockItemId">
|
||||
<option value="0">-- Select Stock Item --</option>
|
||||
@foreach (var group in stockItemGroups)
|
||||
{
|
||||
<optgroup label="@group.Key">
|
||||
@foreach (var si in group.Value)
|
||||
{
|
||||
<option value="@si.Id">@si.Material.Size - @ArchUnits.FormatFromInches((double)si.LengthInches)</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<InputNumber class="form-control" @bind-Value="item.Quantity" min="1" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Supplier (optional)</label>
|
||||
<select class="form-select" @bind="item.SupplierId">
|
||||
<option value="">-- Select Supplier --</option>
|
||||
@foreach (var supplier in suppliers)
|
||||
{
|
||||
<option value="@supplier.Id">@supplier.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Job (optional)</label>
|
||||
<select class="form-select" @bind="item.JobId">
|
||||
<option value="">-- Select Job --</option>
|
||||
@foreach (var job in jobs)
|
||||
{
|
||||
<option value="@job.Id">@job.DisplayName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<InputText class="form-control" @bind-Value="item.Notes" placeholder="Any notes about this order" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" disabled="@saving">
|
||||
@if (saving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Add to Order List
|
||||
</button>
|
||||
<a href="orders" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private PurchaseItem item = new() { Quantity = 1 };
|
||||
private List<StockItem> stockItems = new();
|
||||
private Dictionary<string, List<StockItem>> stockItemGroups = new();
|
||||
private List<Supplier> suppliers = new();
|
||||
private List<Job> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
@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);
|
||||
}
|
||||
Reference in New Issue
Block a user