Files
CutList/CutList.Web/Components/Pages/Stock/Edit.razor
AJ Isaacs 6388e003d3 feat: Update UI for Jobs and enhanced Materials
Navigation:
- Rename Projects to Jobs in NavMenu
- Add new icon for multi-material boxes

Home page:
- Update references from Projects to Jobs

Materials pages:
- Add Type and Grade columns to index
- Shape-specific dimension editing with typed inputs
- Error handling with detailed messages

Stock pages:
- Show Shape, Type, Grade, Size columns
- Display QuantityOnHand with badges

Shared components:
- LengthInput: Add nullable binding mode for optional dimensions
- LengthInput: Format on blur for better UX
- CutListReport: Update for Job model references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:15 -05:00

572 lines
24 KiB
Plaintext

@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>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="stockItem.Notes" placeholder="Internal notes" />
</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 mb-4">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
Inventory
<span class="badge @(stockItem.QuantityOnHand > 0 ? "bg-success" : "bg-secondary") ms-2">@stockItem.QuantityOnHand on hand</span>
</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowStockForm">Add/Adjust Stock</button>
</div>
<div class="card-body">
@if (showStockForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>Stock Transaction</h6>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Type</label>
<select class="form-select" @bind="stockTransactionType">
<option value="add">Receive Stock</option>
<option value="adjust">Set Quantity</option>
<option value="scrap">Scrap/Waste</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">@(stockTransactionType == "adjust" ? "New Quantity" : "Quantity")</label>
<input type="number" class="form-control" @bind="stockQuantity" min="0" />
</div>
<div class="col-md-4">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="stockNotes" />
</div>
@if (stockTransactionType == "add")
{
<div class="col-md-6">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="stockSupplierId">
<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">Unit Price (optional)</label>
<input type="number" class="form-control" @bind="stockUnitPrice" step="0.01" min="0" placeholder="0.00" />
</div>
}
</div>
@if (!string.IsNullOrEmpty(stockFormErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@stockFormErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveStockTransactionAsync" disabled="@savingStockTransaction">
@if (savingStockTransaction)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Save
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
}
@if (transactions.Count == 0)
{
<p class="text-muted">No transaction history yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Qty</th>
<th>Supplier</th>
<th>Price</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach (var txn in transactions)
{
<tr>
<td>@txn.CreatedAt.ToLocalTime().ToString("MM/dd/yy HH:mm")</td>
<td>
<span class="badge @GetTransactionBadgeClass(txn.Type)">@txn.Type</span>
</td>
<td class="@(txn.Quantity >= 0 ? "text-success" : "text-danger")">
@(txn.Quantity >= 0 ? "+" : "")@txn.Quantity
</td>
<td>@(txn.Supplier?.Name ?? "-")</td>
<td>@(txn.UnitPrice.HasValue ? txn.UnitPrice.Value.ToString("C") : "-")</td>
<td>@(txn.Notes ?? "-")</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<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 List<StockTransaction> transactions = 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;
// Stock transaction form
private bool showStockForm;
private bool savingStockTransaction;
private string stockTransactionType = "add";
private int stockQuantity;
private int stockSupplierId;
private decimal? stockUnitPrice;
private string? stockNotes;
private string? stockFormErrorMessage;
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();
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
}
loading = false;
}
private string GetTransactionBadgeClass(StockTransactionType type) => type switch
{
StockTransactionType.Received => "bg-success",
StockTransactionType.Used => "bg-primary",
StockTransactionType.Adjustment => "bg-warning text-dark",
StockTransactionType.Scrapped => "bg-danger",
StockTransactionType.Returned => "bg-info",
_ => "bg-secondary"
};
private void ShowStockForm()
{
stockTransactionType = "add";
stockQuantity = 0;
stockSupplierId = 0;
stockUnitPrice = null;
stockNotes = null;
stockFormErrorMessage = null;
showStockForm = true;
}
private void CancelStockForm()
{
showStockForm = false;
stockFormErrorMessage = null;
}
private async Task SaveStockTransactionAsync()
{
stockFormErrorMessage = null;
savingStockTransaction = true;
try
{
if (stockQuantity <= 0 && stockTransactionType != "adjust")
{
stockFormErrorMessage = "Quantity must be greater than zero";
return;
}
if (stockTransactionType == "adjust" && stockQuantity < 0)
{
stockFormErrorMessage = "Quantity cannot be negative";
return;
}
switch (stockTransactionType)
{
case "add":
await StockItemService.AddStockAsync(
Id!.Value,
stockQuantity,
stockSupplierId > 0 ? stockSupplierId : null,
stockUnitPrice,
stockNotes);
break;
case "adjust":
await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes);
break;
case "scrap":
await StockItemService.ScrapStockAsync(Id!.Value, stockQuantity, stockNotes);
break;
}
// Refresh
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
}
transactions = await StockItemService.GetTransactionHistoryAsync(Id!.Value, 20);
showStockForm = false;
}
finally
{
savingStockTransaction = 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();
}
}
}
}