feat: Add Stock Items UI and update Supplier offerings
- Add Stock Items index page listing all stock items - Add Stock Items edit page with supplier offerings management - Update Suppliers edit page to manage offerings (select from stock items instead of material+length) - Add Stock Items navigation link to sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
371
CutList.Web/Components/Pages/Stock/Edit.razor
Normal file
371
CutList.Web/Components/Pages/Stock/Edit.razor
Normal file
@@ -0,0 +1,371 @@
|
||||
@page "/stock/new"
|
||||
@page "/stock/{Id:int}"
|
||||
@inject StockItemService StockItemService
|
||||
@inject MaterialService MaterialService
|
||||
@inject SupplierService SupplierService
|
||||
@inject NavigationManager Navigation
|
||||
@using CutList.Core.Formatting
|
||||
|
||||
<PageTitle>@(IsNew ? "Add Stock Item" : "Edit Stock Item")</PageTitle>
|
||||
|
||||
<h1>@(IsNew ? "Add Stock Item" : $"{stockItem.Material?.DisplayName} - {ArchUnits.FormatFromInches((double)stockItem.LengthInches)}")</h1>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Stock Item Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="stockItem" OnValidSubmit="SaveStockItemAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Material</label>
|
||||
<select class="form-select" @bind="stockItem.MaterialId" disabled="@(!IsNew)">
|
||||
<option value="0">-- Select Material --</option>
|
||||
@foreach (var material in materials)
|
||||
{
|
||||
<option value="@material.Id">@material.DisplayName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Length</label>
|
||||
@if (IsNew)
|
||||
{
|
||||
<LengthInput @bind-Value="stockItem.LengthInches" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="text" class="form-control" value="@ArchUnits.FormatFromInches((double)stockItem.LengthInches)" readonly />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name (optional)</label>
|
||||
<InputText class="form-control" @bind-Value="stockItem.Name" placeholder="Custom display name" />
|
||||
</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>
|
||||
}
|
||||
@(IsNew ? "Create Stock Item" : "Save Changes")
|
||||
</button>
|
||||
<a href="stock" class="btn btn-outline-secondary">@(IsNew ? "Cancel" : "Back to List")</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsNew)
|
||||
{
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Supplier Offerings</h5>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showOfferingForm)
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Supplier</label>
|
||||
<select class="form-select" @bind="newOffering.SupplierId">
|
||||
<option value="0">-- Select Supplier --</option>
|
||||
@foreach (var supplier in suppliers)
|
||||
{
|
||||
<option value="@supplier.Id">@supplier.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Part Number</label>
|
||||
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Price</label>
|
||||
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Supplier Description</label>
|
||||
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<InputText class="form-control" @bind-Value="newOffering.Notes" />
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(offeringErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
|
||||
@if (savingOffering)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
@(editingOffering == null ? "Add" : "Save")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (offerings.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No supplier offerings configured yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Supplier</th>
|
||||
<th>Part #</th>
|
||||
<th>Price</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var offering in offerings)
|
||||
{
|
||||
<tr>
|
||||
<td>@offering.Supplier.Name</td>
|
||||
<td>@(offering.PartNumber ?? "-")</td>
|
||||
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ConfirmDialog @ref="deleteOfferingDialog"
|
||||
Title="Delete Offering"
|
||||
Message="@deleteOfferingMessage"
|
||||
ConfirmText="Delete"
|
||||
OnConfirm="DeleteOfferingConfirmed" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int? Id { get; set; }
|
||||
|
||||
private StockItem stockItem = new();
|
||||
private List<Material> materials = new();
|
||||
private List<Supplier> suppliers = new();
|
||||
private List<SupplierOffering> offerings = new();
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool savingOffering;
|
||||
private string? errorMessage;
|
||||
private string? offeringErrorMessage;
|
||||
|
||||
private bool showOfferingForm;
|
||||
private SupplierOffering newOffering = new();
|
||||
private SupplierOffering? editingOffering;
|
||||
|
||||
private ConfirmDialog deleteOfferingDialog = null!;
|
||||
private SupplierOffering? offeringToDelete;
|
||||
private string deleteOfferingMessage = "";
|
||||
|
||||
private bool IsNew => !Id.HasValue;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
materials = await MaterialService.GetAllAsync();
|
||||
suppliers = await SupplierService.GetAllAsync();
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
var existing = await StockItemService.GetByIdAsync(Id.Value);
|
||||
if (existing == null)
|
||||
{
|
||||
Navigation.NavigateTo("stock");
|
||||
return;
|
||||
}
|
||||
stockItem = existing;
|
||||
offerings = existing.SupplierOfferings.Where(o => o.IsActive).ToList();
|
||||
}
|
||||
loading = 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;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAddOfferingForm()
|
||||
{
|
||||
editingOffering = null;
|
||||
newOffering = new SupplierOffering { StockItemId = Id!.Value };
|
||||
showOfferingForm = true;
|
||||
offeringErrorMessage = null;
|
||||
}
|
||||
|
||||
private void EditOffering(SupplierOffering offering)
|
||||
{
|
||||
editingOffering = offering;
|
||||
newOffering = new SupplierOffering
|
||||
{
|
||||
Id = offering.Id,
|
||||
StockItemId = offering.StockItemId,
|
||||
SupplierId = offering.SupplierId,
|
||||
PartNumber = offering.PartNumber,
|
||||
SupplierDescription = offering.SupplierDescription,
|
||||
Price = offering.Price,
|
||||
Notes = offering.Notes
|
||||
};
|
||||
showOfferingForm = true;
|
||||
offeringErrorMessage = null;
|
||||
}
|
||||
|
||||
private void CancelOfferingForm()
|
||||
{
|
||||
showOfferingForm = false;
|
||||
editingOffering = null;
|
||||
offeringErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task SaveOfferingAsync()
|
||||
{
|
||||
offeringErrorMessage = null;
|
||||
savingOffering = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (newOffering.SupplierId == 0)
|
||||
{
|
||||
offeringErrorMessage = "Please select a supplier";
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await SupplierService.OfferingExistsAsync(
|
||||
newOffering.SupplierId,
|
||||
newOffering.StockItemId,
|
||||
editingOffering?.Id);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
offeringErrorMessage = "This supplier already has an offering for this stock item";
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingOffering == null)
|
||||
{
|
||||
await SupplierService.AddOfferingAsync(newOffering);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SupplierService.UpdateOfferingAsync(newOffering);
|
||||
}
|
||||
|
||||
// Refresh the stock item to get updated offerings
|
||||
var updated = await StockItemService.GetByIdAsync(Id!.Value);
|
||||
if (updated != null)
|
||||
{
|
||||
stockItem = updated;
|
||||
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
|
||||
}
|
||||
showOfferingForm = false;
|
||||
editingOffering = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingOffering = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfirmDeleteOffering(SupplierOffering offering)
|
||||
{
|
||||
offeringToDelete = offering;
|
||||
deleteOfferingMessage = $"Are you sure you want to delete the offering from {offering.Supplier.Name}?";
|
||||
deleteOfferingDialog.Show();
|
||||
}
|
||||
|
||||
private async Task DeleteOfferingConfirmed()
|
||||
{
|
||||
if (offeringToDelete != null)
|
||||
{
|
||||
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
|
||||
|
||||
// Refresh the stock item to get updated offerings
|
||||
var updated = await StockItemService.GetByIdAsync(Id!.Value);
|
||||
if (updated != null)
|
||||
{
|
||||
stockItem = updated;
|
||||
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
CutList.Web/Components/Pages/Stock/Index.razor
Normal file
85
CutList.Web/Components/Pages/Stock/Index.razor
Normal file
@@ -0,0 +1,85 @@
|
||||
@page "/stock"
|
||||
@inject StockItemService StockItemService
|
||||
@inject NavigationManager Navigation
|
||||
@using CutList.Core.Formatting
|
||||
|
||||
<PageTitle>Stock Items</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Stock Items</h1>
|
||||
<a href="stock/new" class="btn btn-primary">Add Stock Item</a>
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (stockItems.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No stock items found. <a href="stock/new">Add your first stock item</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Material</th>
|
||||
<th>Length</th>
|
||||
<th>Name</th>
|
||||
<th style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in stockItems)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Material.DisplayName</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
||||
<td>@(item.Name ?? "-")</td>
|
||||
<td>
|
||||
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<ConfirmDialog @ref="deleteDialog"
|
||||
Title="Delete Stock Item"
|
||||
Message="@deleteMessage"
|
||||
ConfirmText="Delete"
|
||||
OnConfirm="DeleteConfirmed" />
|
||||
|
||||
@code {
|
||||
private List<StockItem> stockItems = new();
|
||||
private bool loading = true;
|
||||
private ConfirmDialog deleteDialog = null!;
|
||||
private StockItem? itemToDelete;
|
||||
private string deleteMessage = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
stockItems = await StockItemService.GetAllAsync();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private void ConfirmDelete(StockItem item)
|
||||
{
|
||||
itemToDelete = item;
|
||||
deleteMessage = $"Are you sure you want to delete \"{item.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.LengthInches)}\"?";
|
||||
deleteDialog.Show();
|
||||
}
|
||||
|
||||
private async Task DeleteConfirmed()
|
||||
{
|
||||
if (itemToDelete != null)
|
||||
{
|
||||
await StockItemService.DeleteAsync(itemToDelete.Id);
|
||||
stockItems = await StockItemService.GetAllAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user