From 6388e003d32b6c93c1b4001f12351d104375ac7c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Feb 2026 23:38:15 -0500 Subject: [PATCH] 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 --- CutList.Web/Components/Layout/NavMenu.razor | 4 +- .../Components/Layout/NavMenu.razor.css | 4 + CutList.Web/Components/Pages/Home.razor | 8 +- .../Components/Pages/Materials/Edit.razor | 461 ++++++++++-------- .../Components/Pages/Materials/Index.razor | 36 +- CutList.Web/Components/Pages/Stock/Edit.razor | 202 +++++++- .../Components/Pages/Stock/Index.razor | 32 +- .../Components/Pages/Suppliers/Index.razor | 8 +- .../Components/Pages/Tools/Index.razor | 14 +- .../Components/Shared/CutListReport.razor | 16 +- .../Components/Shared/LengthInput.razor | 77 ++- 11 files changed, 627 insertions(+), 235 deletions(-) diff --git a/CutList.Web/Components/Layout/NavMenu.razor b/CutList.Web/Components/Layout/NavMenu.razor index 3329aac..5adf4b9 100644 --- a/CutList.Web/Components/Layout/NavMenu.razor +++ b/CutList.Web/Components/Layout/NavMenu.razor @@ -14,8 +14,8 @@ - @if (!IsNew) + @if (selectedShape != null) { -
+
-
-
Available Stock Lengths
- +
+
Preview
- @if (showStockForm) - { -
-
@(editingStock == null ? "Add Stock Length" : "Edit Stock Length")
-
-
- - -
-
- - -
-
- - -
-
- @if (!string.IsNullOrEmpty(stockErrorMessage)) - { -
@stockErrorMessage
- } -
- - -
-
- } +
+
Shape
+
@selectedShape.Value.GetDisplayName()
- @if (stockLengths.Count == 0) - { -

No stock lengths configured yet.

-

Add common stock lengths for this material (e.g., 20', 24') to quickly populate project stock bins.

- } - else - { - - - - - - - - - - - @foreach (var stock in stockLengths) - { - - - - - - - } - -
LengthQtyNotesActions
@ArchUnits.FormatFromInches((double)stock.LengthInches)@stock.Quantity@(stock.Notes ?? "-") - - -
- } +
Type
+
@material.Type
+ + @if (!string.IsNullOrWhiteSpace(material.Grade)) + { +
Grade
+
@material.Grade
+ } + +
Size
+
@GetPreviewSize()
+ + @if (!string.IsNullOrWhiteSpace(material.Description)) + { +
Description
+
@material.Description
+ } +
@@ -151,33 +127,27 @@ else
} - - @code { [Parameter] public int? Id { get; set; } private Material material = new(); - private List stockLengths = new(); + private MaterialShape? selectedShape; private bool loading = true; private bool saving; private string? errorMessage; - // Stock form - private bool showStockForm; - private bool savingStock; - private MaterialStockLength newStock = new(); - private MaterialStockLength? editingStock; - private string? stockErrorMessage; - - // Delete dialog - private ConfirmDialog deleteStockDialog = null!; - private MaterialStockLength? stockToDelete; - private string deleteStockMessage = ""; + // Typed dimension objects for each shape + private RoundBarDimensions roundBarDims = new(); + private RoundTubeDimensions roundTubeDims = new(); + private FlatBarDimensions flatBarDims = new(); + private SquareBarDimensions squareBarDims = new(); + private SquareTubeDimensions squareTubeDims = new(); + private RectangularTubeDimensions rectTubeDims = new(); + private AngleDimensions angleDims = new(); + private ChannelDimensions channelDims = new(); + private IBeamDimensions ibeamDims = new(); + private PipeDimensions pipeDims = new(); private bool IsNew => !Id.HasValue; @@ -192,11 +162,201 @@ else return; } material = existing; - stockLengths = await MaterialService.GetStockLengthsAsync(Id.Value); + selectedShape = existing.Shape; + LoadDimensionsFromMaterial(existing); } loading = false; } + private void LoadDimensionsFromMaterial(Material m) + { + if (m.Dimensions == null) return; + + switch (m.Dimensions) + { + case RoundBarDimensions d: roundBarDims = d; break; + case RoundTubeDimensions d: roundTubeDims = d; break; + case FlatBarDimensions d: flatBarDims = d; break; + case SquareBarDimensions d: squareBarDims = d; break; + case SquareTubeDimensions d: squareTubeDims = d; break; + case RectangularTubeDimensions d: rectTubeDims = d; break; + case AngleDimensions d: angleDims = d; break; + case ChannelDimensions d: channelDims = d; break; + case IBeamDimensions d: ibeamDims = d; break; + case PipeDimensions d: pipeDims = d; break; + } + } + + private MaterialDimensions GetCurrentDimensions() => selectedShape switch + { + MaterialShape.RoundBar => roundBarDims, + MaterialShape.RoundTube => roundTubeDims, + MaterialShape.FlatBar => flatBarDims, + MaterialShape.SquareBar => squareBarDims, + MaterialShape.SquareTube => squareTubeDims, + MaterialShape.RectangularTube => rectTubeDims, + MaterialShape.Angle => angleDims, + MaterialShape.Channel => channelDims, + MaterialShape.IBeam => ibeamDims, + MaterialShape.Pipe => pipeDims, + _ => throw new InvalidOperationException("No shape selected") + }; + + private void OnShapeChanged() + { + if (selectedShape.HasValue) + { + material.Shape = selectedShape.Value; + } + } + + private string GetPreviewSize() + { + if (!string.IsNullOrWhiteSpace(material.Size)) + { + return material.Size; + } + + if (selectedShape.HasValue) + { + try + { + var generated = GetCurrentDimensions().GenerateSizeString(); + return string.IsNullOrWhiteSpace(generated) ? "(enter dimensions)" : generated; + } + catch + { + return "(enter dimensions)"; + } + } + + return "(select shape and enter dimensions)"; + } + + private RenderFragment RenderDimensionInputs() => __builder => + { + switch (selectedShape!.Value) + { + case MaterialShape.RoundBar: +
+ + +
+ break; + + case MaterialShape.RoundTube: +
+ + +
+
+ + +
+ break; + + case MaterialShape.FlatBar: +
+ + +
+
+ + +
+ break; + + case MaterialShape.SquareBar: +
+ + +
+ break; + + case MaterialShape.SquareTube: +
+ + +
+
+ + +
+ break; + + case MaterialShape.RectangularTube: +
+ + +
+
+ + +
+
+ + +
+ break; + + case MaterialShape.Angle: +
+ + +
+
+ + +
+
+ + +
+ break; + + case MaterialShape.Channel: +
+ + +
+
+ + +
+
+ + +
+ break; + + case MaterialShape.IBeam: +
+ + +
+
+ + +
+ break; + + case MaterialShape.Pipe: +
+ + +
+
+ + +
+
+ + +
+ break; + } + }; + private async Task SaveAsync() { errorMessage = null; @@ -204,15 +364,24 @@ else try { - if (string.IsNullOrWhiteSpace(material.Shape)) + if (!selectedShape.HasValue) { errorMessage = "Shape is required"; return; } + material.Shape = selectedShape.Value; + var dimensions = GetCurrentDimensions(); + + // Auto-generate Size if empty if (string.IsNullOrWhiteSpace(material.Size)) { - errorMessage = "Size is required"; + material.Size = dimensions.GenerateSizeString(); + } + + if (string.IsNullOrWhiteSpace(material.Size)) + { + errorMessage = "Size is required. Please enter dimensions or provide a size string."; return; } @@ -225,12 +394,12 @@ else if (IsNew) { - var created = await MaterialService.CreateAsync(material); + var created = await MaterialService.CreateWithDimensionsAsync(material, dimensions); Navigation.NavigateTo($"materials/{created.Id}"); } else { - await MaterialService.UpdateAsync(material); + await MaterialService.UpdateWithDimensionsAsync(material, dimensions); } } finally @@ -238,94 +407,4 @@ else saving = false; } } - - // Stock length methods - private void ShowAddStockForm() - { - editingStock = null; - newStock = new MaterialStockLength { MaterialId = Id!.Value }; - showStockForm = true; - stockErrorMessage = null; - } - - private void EditStock(MaterialStockLength stock) - { - editingStock = stock; - newStock = new MaterialStockLength - { - Id = stock.Id, - MaterialId = stock.MaterialId, - LengthInches = stock.LengthInches, - Quantity = stock.Quantity, - Notes = stock.Notes - }; - showStockForm = true; - stockErrorMessage = null; - } - - private void CancelStockForm() - { - showStockForm = false; - editingStock = null; - stockErrorMessage = null; - } - - private async Task SaveStockAsync() - { - stockErrorMessage = null; - savingStock = true; - - try - { - if (newStock.LengthInches <= 0) - { - stockErrorMessage = "Length must be greater than zero"; - return; - } - - var exists = await MaterialService.StockLengthExistsAsync( - newStock.MaterialId, - newStock.LengthInches, - editingStock?.Id); - - if (exists) - { - stockErrorMessage = "This stock length already exists for this material"; - return; - } - - if (editingStock == null) - { - await MaterialService.AddStockLengthAsync(newStock); - } - else - { - await MaterialService.UpdateStockLengthAsync(newStock); - } - - stockLengths = await MaterialService.GetStockLengthsAsync(Id!.Value); - showStockForm = false; - editingStock = null; - } - finally - { - savingStock = false; - } - } - - private void ConfirmDeleteStock(MaterialStockLength stock) - { - stockToDelete = stock; - deleteStockMessage = $"Are you sure you want to delete the {ArchUnits.FormatFromInches((double)stock.LengthInches)} stock length?"; - deleteStockDialog.Show(); - } - - private async Task DeleteStockConfirmed() - { - if (stockToDelete != null) - { - await MaterialService.DeleteStockLengthAsync(stockToDelete.Id); - stockLengths = await MaterialService.GetStockLengthsAsync(Id!.Value); - } - } } diff --git a/CutList.Web/Components/Pages/Materials/Index.razor b/CutList.Web/Components/Pages/Materials/Index.razor index 42006c2..7b7ae0a 100644 --- a/CutList.Web/Components/Pages/Materials/Index.razor +++ b/CutList.Web/Components/Pages/Materials/Index.razor @@ -13,6 +13,13 @@ {

Loading...

} +else if (!string.IsNullOrEmpty(errorMessage)) +{ +
+ Error loading materials: +
@errorMessage
+
+} else if (materials.Count == 0) {
@@ -25,21 +32,27 @@ else Shape + Type + Grade Size Description - Actions + Actions @foreach (var material in materials) { - @material.Shape + @material.Shape.GetDisplayName() + @material.Type + @material.Grade @material.Size @material.Description - Edit - +
+ Edit + +
} @@ -56,14 +69,25 @@ else @code { private List materials = new(); private bool loading = true; + private string? errorMessage; private ConfirmDialog deleteDialog = null!; private Material? materialToDelete; private string deleteMessage = ""; protected override async Task OnInitializedAsync() { - materials = await MaterialService.GetAllAsync(); - loading = false; + try + { + materials = await MaterialService.GetAllAsync(); + } + catch (Exception ex) + { + errorMessage = ex.ToString(); + } + finally + { + loading = false; + } } private void ConfirmDelete(Material material) diff --git a/CutList.Web/Components/Pages/Stock/Edit.razor b/CutList.Web/Components/Pages/Stock/Edit.razor index fe267dc..ee896fb 100644 --- a/CutList.Web/Components/Pages/Stock/Edit.razor +++ b/CutList.Web/Components/Pages/Stock/Edit.razor @@ -54,6 +54,11 @@ else
+
+ + +
+ @if (!string.IsNullOrEmpty(errorMessage)) {
@errorMessage
@@ -76,7 +81,111 @@ else @if (!IsNew) { -
+
+
+
+
+ Inventory + @stockItem.QuantityOnHand on hand +
+ +
+
+ @if (showStockForm) + { +
+
Stock Transaction
+
+
+ + +
+
+ + +
+
+ + +
+ @if (stockTransactionType == "add") + { +
+ + +
+
+ + +
+ } +
+ @if (!string.IsNullOrEmpty(stockFormErrorMessage)) + { +
@stockFormErrorMessage
+ } +
+ + +
+
+ } + + @if (transactions.Count == 0) + { +

No transaction history yet.

+ } + else + { + + + + + + + + + + + + + @foreach (var txn in transactions) + { + + + + + + + + + } + +
DateTypeQtySupplierPriceNotes
@txn.CreatedAt.ToLocalTime().ToString("MM/dd/yy HH:mm") + @txn.Type + + @(txn.Quantity >= 0 ? "+" : "")@txn.Quantity + @(txn.Supplier?.Name ?? "-")@(txn.UnitPrice.HasValue ? txn.UnitPrice.Value.ToString("C") : "-")@(txn.Notes ?? "-")
+ } +
+
+
Supplier Offerings
@@ -184,6 +293,7 @@ else private List materials = new(); private List suppliers = new(); private List offerings = new(); + private List transactions = new(); private bool loading = true; private bool saving; private bool savingOffering; @@ -194,6 +304,16 @@ else 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 = ""; @@ -215,10 +335,90 @@ else } 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; diff --git a/CutList.Web/Components/Pages/Stock/Index.razor b/CutList.Web/Components/Pages/Stock/Index.razor index ae7193e..40fb751 100644 --- a/CutList.Web/Components/Pages/Stock/Index.razor +++ b/CutList.Web/Components/Pages/Stock/Index.razor @@ -2,6 +2,7 @@ @inject StockItemService StockItemService @inject NavigationManager Navigation @using CutList.Core.Formatting +@using CutList.Web.Data.Entities Stock Items @@ -25,22 +26,39 @@ else - + + + + - - + + @foreach (var item in stockItems) { - + + + + - + } diff --git a/CutList.Web/Components/Pages/Suppliers/Index.razor b/CutList.Web/Components/Pages/Suppliers/Index.razor index ee07a3c..379b5f7 100644 --- a/CutList.Web/Components/Pages/Suppliers/Index.razor +++ b/CutList.Web/Components/Pages/Suppliers/Index.razor @@ -27,7 +27,7 @@ else - + @@ -38,8 +38,10 @@ else } diff --git a/CutList.Web/Components/Pages/Tools/Index.razor b/CutList.Web/Components/Pages/Tools/Index.razor index ce8092a..21b3e4c 100644 --- a/CutList.Web/Components/Pages/Tools/Index.razor +++ b/CutList.Web/Components/Pages/Tools/Index.razor @@ -1,5 +1,5 @@ @page "/tools" -@inject ProjectService ProjectService +@inject JobService JobService @using CutList.Core.Formatting Cutting Tools @@ -122,7 +122,7 @@ else protected override async Task OnInitializedAsync() { - tools = await ProjectService.GetCuttingToolsAsync(); + tools = await JobService.GetCuttingToolsAsync(); loading = false; } @@ -177,14 +177,14 @@ else if (editingTool == null) { - await ProjectService.CreateCuttingToolAsync(formTool); + await JobService.CreateCuttingToolAsync(formTool); } else { - await ProjectService.UpdateCuttingToolAsync(formTool); + await JobService.UpdateCuttingToolAsync(formTool); } - tools = await ProjectService.GetCuttingToolsAsync(); + tools = await JobService.GetCuttingToolsAsync(); showForm = false; editingTool = null; } @@ -205,8 +205,8 @@ else { if (toolToDelete != null) { - await ProjectService.DeleteCuttingToolAsync(toolToDelete.Id); - tools = await ProjectService.GetCuttingToolsAsync(); + await JobService.DeleteCuttingToolAsync(toolToDelete.Id); + tools = await JobService.GetCuttingToolsAsync(); } } diff --git a/CutList.Web/Components/Shared/CutListReport.razor b/CutList.Web/Components/Shared/CutListReport.razor index 256db9b..da0aa3b 100644 --- a/CutList.Web/Components/Shared/CutListReport.razor +++ b/CutList.Web/Components/Shared/CutListReport.razor @@ -8,14 +8,14 @@

CUT LIST

Date: @DateTime.Now.ToString("g")
-
Project: @Project.Name
- @if (!string.IsNullOrWhiteSpace(Project.Customer)) +
Job: @Job.Name
+ @if (!string.IsNullOrWhiteSpace(Job.Customer)) { -
Customer: @Project.Customer
+
Customer: @Job.Customer
} - @if (Project.CuttingTool != null) + @if (Job.CuttingTool != null) { -
Cut Method: @Project.CuttingTool.Name (kerf: @Project.CuttingTool.KerfInches")
+
Cut Method: @Job.CuttingTool.Name (kerf: @Job.CuttingTool.KerfInches")
}
Stock Bars: @PackResult.Bins.Count
Total Pieces: @TotalPieces
@@ -64,18 +64,18 @@
- @if (!string.IsNullOrEmpty(Project.Notes)) + @if (!string.IsNullOrEmpty(Job.Notes)) {

Notes

-

@Project.Notes

+

@Job.Notes

} @code { [Parameter, EditorRequired] - public Project Project { get; set; } = null!; + public Job Job { get; set; } = null!; [Parameter, EditorRequired] public PackResult PackResult { get; set; } = null!; diff --git a/CutList.Web/Components/Shared/LengthInput.razor b/CutList.Web/Components/Shared/LengthInput.razor index 5ff1976..0c550b0 100644 --- a/CutList.Web/Components/Shared/LengthInput.razor +++ b/CutList.Web/Components/Shared/LengthInput.razor @@ -4,7 +4,8 @@ @if (HasError) { @@ -13,24 +14,53 @@ @code { + /// + /// Non-nullable decimal value binding. + /// [Parameter] public decimal Value { get; set; } [Parameter] public EventCallback ValueChanged { get; set; } + /// + /// Nullable decimal value binding (used for optional dimension fields). + /// Takes precedence over Value if both ValueChanged and NullableValueChanged are set. + /// + [Parameter] + public decimal? NullableValue { get; set; } + + [Parameter] + public EventCallback NullableValueChanged { get; set; } + [Parameter] public string Placeholder { get; set; } = "e.g., 12' 6\" or 144"; private string DisplayValue { get; set; } = string.Empty; private bool HasError { get; set; } private string ErrorMessage { get; set; } = string.Empty; + private decimal? _lastValue; + + private bool IsNullableMode => NullableValueChanged.HasDelegate; + + private decimal? CurrentValue => IsNullableMode ? NullableValue : Value; protected override void OnParametersSet() { - if (Value > 0 && string.IsNullOrEmpty(DisplayValue)) + // Reset display when Value changes externally (e.g., form reset) + if (CurrentValue != _lastValue) { - DisplayValue = ArchUnits.FormatFromInches((double)Value); + _lastValue = CurrentValue; + if (CurrentValue.HasValue && CurrentValue.Value > 0) + { + DisplayValue = ArchUnits.FormatFromInches((double)CurrentValue.Value); + } + else + { + DisplayValue = string.Empty; + HasError = false; + ErrorMessage = string.Empty; + } } } @@ -43,7 +73,15 @@ if (string.IsNullOrWhiteSpace(input)) { - await ValueChanged.InvokeAsync(0); + _lastValue = null; + if (IsNullableMode) + { + await NullableValueChanged.InvokeAsync(null); + } + else + { + await ValueChanged.InvokeAsync(0); + } return; } @@ -51,14 +89,32 @@ { // Try to parse as architectural units var inches = ArchUnits.ParseToInches(input); - await ValueChanged.InvokeAsync((decimal)inches); + _lastValue = (decimal)inches; + + if (IsNullableMode) + { + await NullableValueChanged.InvokeAsync(_lastValue); + } + else + { + await ValueChanged.InvokeAsync(_lastValue.Value); + } } catch { // Try to parse as plain decimal (inches) if (decimal.TryParse(input, out var decimalValue)) { - await ValueChanged.InvokeAsync(decimalValue); + _lastValue = decimalValue; + + if (IsNullableMode) + { + await NullableValueChanged.InvokeAsync(decimalValue); + } + else + { + await ValueChanged.InvokeAsync(decimalValue); + } } else { @@ -68,6 +124,15 @@ } } + private void OnBlur() + { + // Format the display value nicely on blur if we have a valid value + if (!HasError && _lastValue.HasValue && _lastValue.Value > 0) + { + DisplayValue = ArchUnits.FormatFromInches((double)_lastValue.Value); + } + } + public static string FormatLength(decimal inches) { return ArchUnits.FormatFromInches((double)inches);
MaterialShapeTypeGradeSize LengthNameActionsOn HandActions
@item.Material.DisplayName@item.Material.Shape.GetDisplayName()@item.Material.Type@item.Material.Grade@item.Material.Size @ArchUnits.FormatFromInches((double)item.LengthInches)@(item.Name ?? "-") - Edit - + @if (item.QuantityOnHand > 0) + { + @item.QuantityOnHand + } + else + { + 0 + } + +
+ Edit + +
Name Contact Info NotesActionsActions
@supplier.ContactInfo @TruncateText(supplier.Notes, 50) - Edit - +
+ Edit + +