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>
This commit is contained in:
2026-02-04 23:38:15 -05:00
parent c5da5dda98
commit 6388e003d3
11 changed files with 627 additions and 235 deletions

View File

@@ -14,8 +14,8 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="projects">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Projects
<NavLink class="nav-link" href="jobs">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
</NavLink>
</div>
<div class="nav-item px-3">

View File

@@ -46,6 +46,10 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box' viewBox='0 0 16 16'%3E%3Cpath d='M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z'/%3E%3C/svg%3E");
}
.bi-boxes-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-boxes' viewBox='0 0 16 16'%3E%3Cpath d='M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434zM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567zM7.5 9.933l-2.75 1.571v3.134l2.75-1.571zm1 3.134 2.75 1.571v-3.134L8.5 9.933zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567zm2.242-2.433V3.504L8.5 5.076V8.21zM7.5 8.21V5.076L4.75 3.504v3.134zM5.258 2.643 8 4.21l2.742-1.567L8 1.076zM15 9.933l-2.75 1.571v3.134L15 13.067zM3.75 14.638v-3.134L1 9.933v3.134z'/%3E%3C/svg%3E");
}
.bi-building-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-building' viewBox='0 0 16 16'%3E%3Cpath d='M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z'/%3E%3Cpath d='M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z'/%3E%3C/svg%3E");
}

View File

@@ -10,9 +10,9 @@
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Projects</h5>
<p class="card-text">Create and manage cut list projects. Add parts and stock bins, then optimize to minimize waste.</p>
<a href="projects" class="btn btn-primary">Go to Projects</a>
<h5 class="card-title">Jobs</h5>
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
</div>
</div>
</div>
@@ -51,7 +51,7 @@
<ol>
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
<li><strong>Add suppliers</strong> - Track which stock lengths are available from your suppliers</li>
<li><strong>Create a project</strong> - Add the parts you need to cut with their lengths and quantities</li>
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from (import from supplier or add manually)</li>
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>

View File

@@ -3,6 +3,8 @@
@inject MaterialService MaterialService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
@using CutList.Web.Components.Shared
<PageTitle>@(IsNew ? "Add Material" : "Edit Material")</PageTitle>
@@ -26,21 +28,41 @@ else
<div class="mb-3">
<label class="form-label">Shape</label>
<InputSelect class="form-select" @bind-Value="material.Shape">
<InputSelect class="form-select" @bind-Value="selectedShape" @bind-Value:after="OnShapeChanged">
<option value="">-- Select Shape --</option>
@foreach (var shape in MaterialService.CommonShapes)
@foreach (var shape in Enum.GetValues<MaterialShape>())
{
<option value="@shape">@shape</option>
<option value="@shape">@shape.GetDisplayName()</option>
}
</InputSelect>
<ValidationMessage For="@(() => material.Shape)" />
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Type</label>
<InputSelect class="form-select" @bind-Value="material.Type">
@foreach (var type in Enum.GetValues<MaterialType>())
{
<option value="@type">@type</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Grade</label>
<InputText class="form-control" @bind-Value="material.Grade" placeholder="e.g., A36, Hot Roll, 304" />
</div>
</div>
@if (selectedShape != null)
{
@RenderDimensionInputs()
}
<div class="mb-3">
<label class="form-label">Size</label>
<InputText class="form-control" @bind-Value="material.Size" placeholder="e.g., 1&quot; OD x 0.065 wall" />
<ValidationMessage For="@(() => material.Size)" />
<div class="form-text">Examples: "1&quot; OD x 0.065 wall", "2x2", "1.5 x 1.5 x 0.125"</div>
<label class="form-label">Size Display (auto-generated)</label>
<InputText class="form-control" @bind-Value="material.Size" placeholder="Will be auto-generated from dimensions" />
<div class="form-text">Leave blank to auto-generate from dimensions, or customize as needed.</div>
</div>
<div class="mb-3">
@@ -68,82 +90,36 @@ else
</div>
</div>
@if (!IsNew)
@if (selectedShape != null)
{
<div class="col-lg-6">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Available Stock Lengths</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddStockForm">Add Length</button>
<div class="card-header">
<h5 class="mb-0">Preview</h5>
</div>
<div class="card-body">
@if (showStockForm)
<dl class="row mb-0">
<dt class="col-sm-4">Shape</dt>
<dd class="col-sm-8">@selectedShape.Value.GetDisplayName()</dd>
<dt class="col-sm-4">Type</dt>
<dd class="col-sm-8">@material.Type</dd>
@if (!string.IsNullOrWhiteSpace(material.Grade))
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock Length" : "Edit Stock Length")</h6>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-3">
<label class="form-label">Qty in Stock</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="0" />
</div>
<div class="col-md-5">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="newStock.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@stockErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveStockAsync" disabled="@savingStock">
@if (savingStock)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingStock == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
<dt class="col-sm-4">Grade</dt>
<dd class="col-sm-8">@material.Grade</dd>
}
@if (stockLengths.Count == 0)
<dt class="col-sm-4">Size</dt>
<dd class="col-sm-8">@GetPreviewSize()</dd>
@if (!string.IsNullOrWhiteSpace(material.Description))
{
<p class="text-muted">No stock lengths configured yet.</p>
<p class="text-muted small">Add common stock lengths for this material (e.g., 20', 24') to quickly populate project stock bins.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Length</th>
<th>Qty</th>
<th>Notes</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var stock in stockLengths)
{
<tr>
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches)</td>
<td>@stock.Quantity</td>
<td>@(stock.Notes ?? "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteStock(stock)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<dt class="col-sm-4">Description</dt>
<dd class="col-sm-8">@material.Description</dd>
}
</dl>
</div>
</div>
</div>
@@ -151,33 +127,27 @@ else
</div>
}
<ConfirmDialog @ref="deleteStockDialog"
Title="Delete Stock Length"
Message="@deleteStockMessage"
ConfirmText="Delete"
OnConfirm="DeleteStockConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private Material material = new();
private List<MaterialStockLength> 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:
<div class="mb-3">
<label class="form-label">Diameter</label>
<LengthInput @bind-Value="roundBarDims.Diameter" Placeholder="e.g., 1/2&quot; or 0.5" />
</div>
break;
case MaterialShape.RoundTube:
<div class="mb-3">
<label class="form-label">Outer Diameter</label>
<LengthInput @bind-Value="roundTubeDims.OuterDiameter" Placeholder="e.g., 1&quot; or 1.0" />
</div>
<div class="mb-3">
<label class="form-label">Wall Thickness</label>
<LengthInput @bind-Value="roundTubeDims.Wall" Placeholder="e.g., 0.065 or 1/16&quot;" />
</div>
break;
case MaterialShape.FlatBar:
<div class="mb-3">
<label class="form-label">Width</label>
<LengthInput @bind-Value="flatBarDims.Width" Placeholder="e.g., 2&quot; or 2.0" />
</div>
<div class="mb-3">
<label class="form-label">Thickness</label>
<LengthInput @bind-Value="flatBarDims.Thickness" Placeholder="e.g., 1/4&quot; or 0.25" />
</div>
break;
case MaterialShape.SquareBar:
<div class="mb-3">
<label class="form-label">Size</label>
<LengthInput @bind-Value="squareBarDims.Size" Placeholder="e.g., 1&quot; or 1.0" />
</div>
break;
case MaterialShape.SquareTube:
<div class="mb-3">
<label class="form-label">Size</label>
<LengthInput @bind-Value="squareTubeDims.Size" Placeholder="e.g., 2&quot; or 2.0" />
</div>
<div class="mb-3">
<label class="form-label">Wall Thickness</label>
<LengthInput @bind-Value="squareTubeDims.Wall" Placeholder="e.g., 0.125 or 1/8&quot;" />
</div>
break;
case MaterialShape.RectangularTube:
<div class="mb-3">
<label class="form-label">Width</label>
<LengthInput @bind-Value="rectTubeDims.Width" Placeholder="e.g., 2&quot; or 2.0" />
</div>
<div class="mb-3">
<label class="form-label">Height</label>
<LengthInput @bind-Value="rectTubeDims.Height" Placeholder="e.g., 3&quot; or 3.0" />
</div>
<div class="mb-3">
<label class="form-label">Wall Thickness</label>
<LengthInput @bind-Value="rectTubeDims.Wall" Placeholder="e.g., 0.125 or 1/8&quot;" />
</div>
break;
case MaterialShape.Angle:
<div class="mb-3">
<label class="form-label">Leg 1</label>
<LengthInput @bind-Value="angleDims.Leg1" Placeholder="e.g., 2&quot; or 2.0" />
</div>
<div class="mb-3">
<label class="form-label">Leg 2</label>
<LengthInput @bind-Value="angleDims.Leg2" Placeholder="e.g., 2&quot; or 2.0" />
</div>
<div class="mb-3">
<label class="form-label">Thickness</label>
<LengthInput @bind-Value="angleDims.Thickness" Placeholder="e.g., 1/4&quot; or 0.25" />
</div>
break;
case MaterialShape.Channel:
<div class="mb-3">
<label class="form-label">Height</label>
<LengthInput @bind-Value="channelDims.Height" Placeholder="e.g., 4&quot; or 4.0" />
</div>
<div class="mb-3">
<label class="form-label">Flange Width</label>
<LengthInput @bind-Value="channelDims.Flange" Placeholder="e.g., 1.58" />
</div>
<div class="mb-3">
<label class="form-label">Web Thickness</label>
<LengthInput @bind-Value="channelDims.Web" Placeholder="e.g., 0.18" />
</div>
break;
case MaterialShape.IBeam:
<div class="mb-3">
<label class="form-label">Height (nominal)</label>
<LengthInput @bind-Value="ibeamDims.Height" Placeholder="e.g., 8" />
</div>
<div class="mb-3">
<label class="form-label">Weight per Foot (lbs)</label>
<input type="number" class="form-control" step="0.01" @bind="ibeamDims.WeightPerFoot" placeholder="e.g., 31" />
</div>
break;
case MaterialShape.Pipe:
<div class="mb-3">
<label class="form-label">Nominal Pipe Size (NPS)</label>
<LengthInput @bind-Value="pipeDims.NominalSize" Placeholder="e.g., 1&quot; or 1.0" />
</div>
<div class="mb-3">
<label class="form-label">Schedule (optional)</label>
<InputText class="form-control" @bind-Value="pipeDims.Schedule" placeholder="e.g., 40, 80, STD" />
</div>
<div class="mb-3">
<label class="form-label">Wall Thickness (if no schedule)</label>
<LengthInput @bind-NullableValue="pipeDims.Wall" Placeholder="e.g., 0.133" />
</div>
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);
}
}
}

View File

@@ -13,6 +13,13 @@
{
<p><em>Loading...</em></p>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">
<strong>Error loading materials:</strong>
<pre style="white-space: pre-wrap;">@errorMessage</pre>
</div>
}
else if (materials.Count == 0)
{
<div class="alert alert-info">
@@ -25,21 +32,27 @@ else
<thead>
<tr>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th>Description</th>
<th style="width: 120px;">Actions</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var material in materials)
{
<tr>
<td>@material.Shape</td>
<td>@material.Shape.GetDisplayName()</td>
<td>@material.Type</td>
<td>@material.Grade</td>
<td>@material.Size</td>
<td>@material.Description</td>
<td>
<div class="d-flex gap-1">
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)">Delete</button>
</div>
</td>
</tr>
}
@@ -56,15 +69,26 @@ else
@code {
private List<Material> materials = new();
private bool loading = true;
private string? errorMessage;
private ConfirmDialog deleteDialog = null!;
private Material? materialToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
try
{
materials = await MaterialService.GetAllAsync();
}
catch (Exception ex)
{
errorMessage = ex.ToString();
}
finally
{
loading = false;
}
}
private void ConfirmDelete(Material material)
{

View File

@@ -54,6 +54,11 @@ else
<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>
@@ -76,7 +81,111 @@ else
@if (!IsNew)
{
<div class="col-lg-6">
<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>
@@ -184,6 +293,7 @@ else
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;
@@ -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;

View File

@@ -2,6 +2,7 @@
@inject StockItemService StockItemService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Stock Items</PageTitle>
@@ -25,22 +26,39 @@ else
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th>Length</th>
<th>Name</th>
<th style="width: 120px;">Actions</th>
<th>On Hand</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in stockItems)
{
<tr>
<td>@item.Material.DisplayName</td>
<td>@item.Material.Shape.GetDisplayName()</td>
<td>@item.Material.Type</td>
<td>@item.Material.Grade</td>
<td>@item.Material.Size</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td>@(item.Name ?? "-")</td>
<td>
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success">@item.QuantityOnHand</span>
}
else
{
<span class="badge bg-secondary">0</span>
}
</td>
<td>
<div class="d-flex gap-1">
<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>
</div>
</td>
</tr>
}

View File

@@ -27,7 +27,7 @@ else
<th>Name</th>
<th>Contact Info</th>
<th>Notes</th>
<th style="width: 120px;">Actions</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@@ -38,8 +38,10 @@ else
<td>@supplier.ContactInfo</td>
<td>@TruncateText(supplier.Notes, 50)</td>
<td>
<div class="d-flex gap-1">
<a href="suppliers/@supplier.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(supplier)">Delete</button>
</div>
</td>
</tr>
}

View File

@@ -1,5 +1,5 @@
@page "/tools"
@inject ProjectService ProjectService
@inject JobService JobService
@using CutList.Core.Formatting
<PageTitle>Cutting Tools</PageTitle>
@@ -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();
}
}

View File

@@ -8,14 +8,14 @@
<h1>CUT LIST</h1>
<div class="meta-info">
<div class="meta-row"><span>Date:</span> @DateTime.Now.ToString("g")</div>
<div class="meta-row"><span>Project:</span> @Project.Name</div>
@if (!string.IsNullOrWhiteSpace(Project.Customer))
<div class="meta-row"><span>Job:</span> @Job.Name</div>
@if (!string.IsNullOrWhiteSpace(Job.Customer))
{
<div class="meta-row"><span>Customer:</span> @Project.Customer</div>
<div class="meta-row"><span>Customer:</span> @Job.Customer</div>
}
@if (Project.CuttingTool != null)
@if (Job.CuttingTool != null)
{
<div class="meta-row"><span>Cut Method:</span> @Project.CuttingTool.Name (kerf: @Project.CuttingTool.KerfInches")</div>
<div class="meta-row"><span>Cut Method:</span> @Job.CuttingTool.Name (kerf: @Job.CuttingTool.KerfInches")</div>
}
<div class="meta-row"><span>Stock Bars:</span> @PackResult.Bins.Count</div>
<div class="meta-row"><span>Total Pieces:</span> @TotalPieces</div>
@@ -64,18 +64,18 @@
</div>
</footer>
@if (!string.IsNullOrEmpty(Project.Notes))
@if (!string.IsNullOrEmpty(Job.Notes))
{
<div class="notes-section">
<h3>Notes</h3>
<p>@Project.Notes</p>
<p>@Job.Notes</p>
</div>
}
</div>
@code {
[Parameter, EditorRequired]
public Project Project { get; set; } = null!;
public Job Job { get; set; } = null!;
[Parameter, EditorRequired]
public PackResult PackResult { get; set; } = null!;

View File

@@ -4,7 +4,8 @@
<input type="text"
class="form-control @(HasError ? "is-invalid" : "")"
value="@DisplayValue"
@onchange="OnInputChange"
@oninput="OnInputChange"
@onblur="OnBlur"
placeholder="@Placeholder" />
@if (HasError)
{
@@ -13,24 +14,53 @@
</div>
@code {
/// <summary>
/// Non-nullable decimal value binding.
/// </summary>
[Parameter]
public decimal Value { get; set; }
[Parameter]
public EventCallback<decimal> ValueChanged { get; set; }
/// <summary>
/// Nullable decimal value binding (used for optional dimension fields).
/// Takes precedence over Value if both ValueChanged and NullableValueChanged are set.
/// </summary>
[Parameter]
public decimal? NullableValue { get; set; }
[Parameter]
public EventCallback<decimal?> 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;
}
}
}
@@ -42,8 +72,16 @@
ErrorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
_lastValue = null;
if (IsNullableMode)
{
await NullableValueChanged.InvokeAsync(null);
}
else
{
await ValueChanged.InvokeAsync(0);
}
return;
}
@@ -51,15 +89,33 @@
{
// 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))
{
_lastValue = decimalValue;
if (IsNullableMode)
{
await NullableValueChanged.InvokeAsync(decimalValue);
}
else
{
await ValueChanged.InvokeAsync(decimalValue);
}
}
else
{
HasError = true;
@@ -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);