feat: Add CutList.Web Blazor Server application

Add a new web-based frontend for cut list optimization using:
- Blazor Server with .NET 8
- Entity Framework Core with MSSQL LocalDB
- Full CRUD for Materials, Suppliers, Projects, and Cutting Tools
- Supplier stock length management for quick project setup
- Integration with CutList.Core for bin packing optimization
- Print-friendly HTML reports with efficiency statistics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:56:21 -05:00
parent 6db8ab21f4
commit 9868df162d
43 changed files with 4452 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
@page "/"
<PageTitle>CutList - Home</PageTitle>
<h1>CutList</h1>
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
<div class="row mt-4">
<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>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Materials</h5>
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Suppliers</h5>
<p class="card-text">Track suppliers and their available stock lengths for quick project setup.</p>
<a href="suppliers" class="btn btn-outline-primary">Manage Suppliers</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Cutting Tools</h5>
<p class="card-text">Configure cutting tools with their kerf widths for accurate waste calculations.</p>
<a href="tools" class="btn btn-outline-primary">Manage Tools</a>
</div>
</div>
</div>
</div>
<hr class="my-4" />
<h4>How It Works</h4>
<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>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>
</ol>

View File

@@ -0,0 +1,133 @@
@page "/materials/new"
@page "/materials/{Id:int}"
@inject MaterialService MaterialService
@inject NavigationManager Navigation
<PageTitle>@(IsNew ? "Add Material" : "Edit Material")</PageTitle>
<h1>@(IsNew ? "Add Material" : "Edit Material")</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-md-6">
<EditForm Model="material" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Shape</label>
<InputSelect class="form-select" @bind-Value="material.Shape">
<option value="">-- Select Shape --</option>
@foreach (var shape in MaterialService.CommonShapes)
{
<option value="@shape">@shape</option>
}
</InputSelect>
<ValidationMessage For="@(() => material.Shape)" />
</div>
<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>
</div>
<div class="mb-3">
<label class="form-label">Description (optional)</label>
<InputText class="form-control" @bind-Value="material.Description" placeholder="Optional description" />
</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" : "Save")
</button>
<a href="materials" class="btn btn-outline-secondary">Cancel</a>
</div>
</EditForm>
</div>
</div>
}
@code {
[Parameter]
public int? Id { get; set; }
private Material material = new();
private bool loading = true;
private bool saving;
private string? errorMessage;
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
var existing = await MaterialService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("materials");
return;
}
material = existing;
}
loading = false;
}
private async Task SaveAsync()
{
errorMessage = null;
saving = true;
try
{
if (string.IsNullOrWhiteSpace(material.Shape))
{
errorMessage = "Shape is required";
return;
}
if (string.IsNullOrWhiteSpace(material.Size))
{
errorMessage = "Size is required";
return;
}
// Check for duplicates
if (await MaterialService.ExistsAsync(material.Shape, material.Size, Id))
{
errorMessage = "A material with this shape and size already exists";
return;
}
if (IsNew)
{
await MaterialService.CreateAsync(material);
}
else
{
await MaterialService.UpdateAsync(material);
}
Navigation.NavigateTo("materials");
}
finally
{
saving = false;
}
}
}

View File

@@ -0,0 +1,84 @@
@page "/materials"
@inject MaterialService MaterialService
@inject NavigationManager Navigation
<PageTitle>Materials</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Materials</h1>
<a href="materials/new" class="btn btn-primary">Add Material</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (materials.Count == 0)
{
<div class="alert alert-info">
No materials found. <a href="materials/new">Add your first material</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Shape</th>
<th>Size</th>
<th>Description</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var material in materials)
{
<tr>
<td>@material.Shape</td>
<td>@material.Size</td>
<td>@material.Description</td>
<td>
<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>
</td>
</tr>
}
</tbody>
</table>
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Material"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Material> materials = new();
private bool loading = true;
private ConfirmDialog deleteDialog = null!;
private Material? materialToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(Material material)
{
materialToDelete = material;
deleteMessage = $"Are you sure you want to delete \"{material.Shape} - {material.Size}\"?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (materialToDelete != null)
{
await MaterialService.DeleteAsync(materialToDelete.Id);
materials = await MaterialService.GetAllAsync();
}
}
}

View File

@@ -0,0 +1,503 @@
@page "/projects/new"
@page "/projects/{Id:int}"
@inject ProjectService ProjectService
@inject MaterialService MaterialService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
<PageTitle>@(IsNew ? "New Project" : project.Name)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(IsNew ? "New Project" : project.Name)</h1>
@if (!IsNew)
{
<a href="projects/@Id/results" class="btn btn-success">Run Optimization</a>
}
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<!-- Project Details -->
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Project Details</h5>
</div>
<div class="card-body">
<EditForm Model="project" OnValidSubmit="SaveProjectAsync">
<div class="mb-3">
<label class="form-label">Project Name</label>
<InputText class="form-control" @bind-Value="project.Name" />
</div>
<div class="mb-3">
<label class="form-label">Material</label>
<InputSelect class="form-select" @bind-Value="project.MaterialId">
<option value="">-- Select Material --</option>
@foreach (var material in materials)
{
<option value="@material.Id">@material.DisplayName</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Cutting Tool</label>
<InputSelect class="form-select" @bind-Value="project.CuttingToolId">
<option value="">-- Select Tool --</option>
@foreach (var tool in cuttingTools)
{
<option value="@tool.Id">@tool.Name (@tool.KerfInches" kerf)</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="project.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(projectErrorMessage))
{
<div class="alert alert-danger">@projectErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingProject">
@if (savingProject)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Project" : "Save")
</button>
<a href="projects" class="btn btn-outline-secondary">Back</a>
</div>
</EditForm>
</div>
</div>
</div>
@if (!IsNew)
{
<!-- Parts -->
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Parts</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddPartForm">Add Part</button>
</div>
<div class="card-body">
@if (showPartForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="newPart.Name" placeholder="Part name" />
</div>
<div class="col-8">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newPart.LengthInches" />
</div>
<div class="col-4">
<label class="form-label">Qty</label>
<input type="number" class="form-control" @bind="newPart.Quantity" min="1" />
</div>
</div>
@if (!string.IsNullOrEmpty(partErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0 py-1">@partErrorMessage</div>
}
<div class="mt-2 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SavePartAsync">@(editingPart == null ? "Add" : "Save")</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelPartForm">Cancel</button>
</div>
</div>
}
@if (project.Parts.Count == 0)
{
<p class="text-muted mb-0">No parts added yet.</p>
}
else
{
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Name</th>
<th>Length</th>
<th>Qty</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@foreach (var part in project.Parts)
{
<tr>
<td>@part.Name</td>
<td>@ArchUnits.FormatFromInches((double)part.LengthInches)</td>
<td>@part.Quantity</td>
<td>
<button class="btn btn-sm btn-link p-0 me-2" @onclick="() => EditPart(part)">Edit</button>
<button class="btn btn-sm btn-link p-0 text-danger" @onclick="() => DeletePart(part)">Del</button>
</td>
</tr>
}
</tbody>
</table>
<div class="mt-2 text-muted small">
Total: @project.Parts.Sum(p => p.Quantity) pieces
</div>
}
</div>
</div>
</div>
<!-- Stock Bins -->
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock Bins</h5>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="ShowImportDialog">Import</button>
<button class="btn btn-sm btn-primary" @onclick="ShowAddBinForm">Add</button>
</div>
</div>
<div class="card-body">
@if (showImportDialog)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>Import from Supplier</h6>
<select class="form-select mb-2" @bind="selectedSupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="ImportFromSupplier" disabled="@(selectedSupplierId == 0)">Import</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => showImportDialog = false">Cancel</button>
</div>
</div>
}
@if (showBinForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newBin.LengthInches" />
</div>
<div class="col-3">
<label class="form-label">Qty</label>
<input type="number" class="form-control" @bind="newBin.Quantity" min="-1" />
<div class="form-text">-1 = unlimited</div>
</div>
<div class="col-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newBin.Priority" min="0" />
</div>
</div>
@if (!string.IsNullOrEmpty(binErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0 py-1">@binErrorMessage</div>
}
<div class="mt-2 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveBinAsync">@(editingBin == null ? "Add" : "Save")</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelBinForm">Cancel</button>
</div>
</div>
}
@if (project.StockBins.Count == 0)
{
<p class="text-muted mb-0">No stock bins added yet.</p>
}
else
{
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Length</th>
<th>Qty</th>
<th>Priority</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@foreach (var bin in project.StockBins.OrderBy(b => b.Priority).ThenBy(b => b.LengthInches))
{
<tr>
<td>@ArchUnits.FormatFromInches((double)bin.LengthInches)</td>
<td>@(bin.Quantity == -1 ? "Unlimited" : bin.Quantity.ToString())</td>
<td>@bin.Priority</td>
<td>
<button class="btn btn-sm btn-link p-0 me-2" @onclick="() => EditBin(bin)">Edit</button>
<button class="btn btn-sm btn-link p-0 text-danger" @onclick="() => DeleteBin(bin)">Del</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
@code {
[Parameter]
public int? Id { get; set; }
private Project project = new();
private List<Material> materials = new();
private List<CuttingTool> cuttingTools = new();
private List<Supplier> suppliers = new();
private bool loading = true;
private bool savingProject;
private string? projectErrorMessage;
// Parts form
private bool showPartForm;
private ProjectPart newPart = new();
private ProjectPart? editingPart;
private string? partErrorMessage;
// Bins form
private bool showBinForm;
private ProjectStockBin newBin = new();
private ProjectStockBin? editingBin;
private string? binErrorMessage;
// Import dialog
private bool showImportDialog;
private int selectedSupplierId;
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
cuttingTools = await ProjectService.GetCuttingToolsAsync();
suppliers = await SupplierService.GetAllAsync();
if (Id.HasValue)
{
var existing = await ProjectService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("projects");
return;
}
project = existing;
}
else
{
// Set default cutting tool for new projects
var defaultTool = await ProjectService.GetDefaultCuttingToolAsync();
if (defaultTool != null)
{
project.CuttingToolId = defaultTool.Id;
}
}
loading = false;
}
private async Task SaveProjectAsync()
{
projectErrorMessage = null;
savingProject = true;
try
{
if (string.IsNullOrWhiteSpace(project.Name))
{
projectErrorMessage = "Project name is required";
return;
}
if (IsNew)
{
var created = await ProjectService.CreateAsync(project);
Navigation.NavigateTo($"projects/{created.Id}");
}
else
{
await ProjectService.UpdateAsync(project);
}
}
finally
{
savingProject = false;
}
}
// Parts methods
private void ShowAddPartForm()
{
editingPart = null;
newPart = new ProjectPart { ProjectId = Id!.Value, Quantity = 1 };
showPartForm = true;
partErrorMessage = null;
}
private void EditPart(ProjectPart part)
{
editingPart = part;
newPart = new ProjectPart
{
Id = part.Id,
ProjectId = part.ProjectId,
Name = part.Name,
LengthInches = part.LengthInches,
Quantity = part.Quantity,
SortOrder = part.SortOrder
};
showPartForm = true;
partErrorMessage = null;
}
private void CancelPartForm()
{
showPartForm = false;
editingPart = null;
}
private async Task SavePartAsync()
{
partErrorMessage = null;
if (string.IsNullOrWhiteSpace(newPart.Name))
{
partErrorMessage = "Name is required";
return;
}
if (newPart.LengthInches <= 0)
{
partErrorMessage = "Length must be greater than zero";
return;
}
if (newPart.Quantity < 1)
{
partErrorMessage = "Quantity must be at least 1";
return;
}
if (editingPart == null)
{
await ProjectService.AddPartAsync(newPart);
}
else
{
await ProjectService.UpdatePartAsync(newPart);
}
project = (await ProjectService.GetByIdAsync(Id!.Value))!;
showPartForm = false;
editingPart = null;
}
private async Task DeletePart(ProjectPart part)
{
await ProjectService.DeletePartAsync(part.Id);
project = (await ProjectService.GetByIdAsync(Id!.Value))!;
}
// Bins methods
private void ShowAddBinForm()
{
editingBin = null;
newBin = new ProjectStockBin { ProjectId = Id!.Value, Quantity = -1, Priority = 25 };
showBinForm = true;
binErrorMessage = null;
}
private void EditBin(ProjectStockBin bin)
{
editingBin = bin;
newBin = new ProjectStockBin
{
Id = bin.Id,
ProjectId = bin.ProjectId,
LengthInches = bin.LengthInches,
Quantity = bin.Quantity,
Priority = bin.Priority,
SortOrder = bin.SortOrder
};
showBinForm = true;
binErrorMessage = null;
}
private void CancelBinForm()
{
showBinForm = false;
editingBin = null;
}
private async Task SaveBinAsync()
{
binErrorMessage = null;
if (newBin.LengthInches <= 0)
{
binErrorMessage = "Length must be greater than zero";
return;
}
if (newBin.Quantity < -1 || newBin.Quantity == 0)
{
binErrorMessage = "Quantity must be positive or -1 for unlimited";
return;
}
if (editingBin == null)
{
await ProjectService.AddStockBinAsync(newBin);
}
else
{
await ProjectService.UpdateStockBinAsync(newBin);
}
project = (await ProjectService.GetByIdAsync(Id!.Value))!;
showBinForm = false;
editingBin = null;
}
private async Task DeleteBin(ProjectStockBin bin)
{
await ProjectService.DeleteStockBinAsync(bin.Id);
project = (await ProjectService.GetByIdAsync(Id!.Value))!;
}
// Import methods
private void ShowImportDialog()
{
selectedSupplierId = 0;
showImportDialog = true;
}
private async Task ImportFromSupplier()
{
if (selectedSupplierId > 0)
{
await ProjectService.ImportStockFromSupplierAsync(Id!.Value, selectedSupplierId, project.MaterialId);
project = (await ProjectService.GetByIdAsync(Id!.Value))!;
showImportDialog = false;
}
}
}

View File

@@ -0,0 +1,94 @@
@page "/projects"
@inject ProjectService ProjectService
@inject NavigationManager Navigation
<PageTitle>Projects</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Projects</h1>
<a href="projects/new" class="btn btn-primary">New Project</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (projects.Count == 0)
{
<div class="alert alert-info">
No projects found. <a href="projects/new">Create your first project</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Material</th>
<th>Cutting Tool</th>
<th>Last Modified</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var project in projects)
{
<tr>
<td><a href="projects/@project.Id">@project.Name</a></td>
<td>@(project.Material?.DisplayName ?? "-")</td>
<td>@(project.CuttingTool?.Name ?? "-")</td>
<td>@((project.UpdatedAt ?? project.CreatedAt).ToLocalTime().ToString("g"))</td>
<td>
<a href="projects/@project.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="projects/@project.Id/results" class="btn btn-sm btn-success">Optimize</a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateProject(project)">Copy</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(project)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Project"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Project> projects = new();
private bool loading = true;
private ConfirmDialog deleteDialog = null!;
private Project? projectToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
projects = await ProjectService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(Project project)
{
projectToDelete = project;
deleteMessage = $"Are you sure you want to delete \"{project.Name}\"? This will also delete all parts and stock bins.";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (projectToDelete != null)
{
await ProjectService.DeleteAsync(projectToDelete.Id);
projects = await ProjectService.GetAllAsync();
}
}
private async Task DuplicateProject(Project project)
{
var duplicate = await ProjectService.DuplicateAsync(project.Id);
Navigation.NavigateTo($"projects/{duplicate.Id}");
}
}

View File

@@ -0,0 +1,142 @@
@page "/projects/{Id:int}/results"
@inject ProjectService ProjectService
@inject CutListPackingService PackingService
@inject ReportService ReportService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using CutList.Core.Nesting
@using CutList.Core.Formatting
<PageTitle>Results - @(project?.Name ?? "Project")</PageTitle>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (project == null)
{
<div class="alert alert-danger">Project not found.</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1>@project.Name</h1>
<p class="text-muted mb-0">Optimization Results</p>
</div>
<div>
<a href="projects/@Id" class="btn btn-outline-secondary me-2">Edit Project</a>
<button class="btn btn-primary" @onclick="PrintReport">Print Report</button>
</div>
</div>
@if (!CanOptimize)
{
<div class="alert alert-warning">
<h4>Cannot Optimize</h4>
<ul class="mb-0">
@if (project.Parts.Count == 0)
{
<li>No parts defined. <a href="projects/@Id">Add parts to the project</a>.</li>
}
@if (project.StockBins.Count == 0)
{
<li>No stock bins defined. <a href="projects/@Id">Add stock bins to the project</a>.</li>
}
@if (project.CuttingToolId == null)
{
<li>No cutting tool selected. <a href="projects/@Id">Select a cutting tool</a>.</li>
}
</ul>
</div>
}
else if (packResult != null)
{
@if (packResult.ItemsNotUsed.Count > 0)
{
<div class="alert alert-warning">
<h5>Items Not Placed</h5>
<p>The following @packResult.ItemsNotUsed.Count item(s) could not be placed (probably too long for available stock):</p>
<ul class="mb-0">
@foreach (var item in packResult.ItemsNotUsed.GroupBy(i => new { i.Name, i.Length }))
{
<li>@item.Count() x @item.Key.Name (@ArchUnits.FormatFromInches(item.Key.Length))</li>
}
</ul>
</div>
}
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary!.TotalBins</h2>
<p class="card-text text-muted">Stock Bars</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
<p class="card-text text-muted">Total Pieces</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
<p class="card-text text-muted">Total Waste</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
<p class="card-text text-muted">Efficiency</p>
</div>
</div>
</div>
</div>
<!-- Report -->
<CutListReport Project="project" PackResult="packResult" />
}
}
@code {
[Parameter]
public int Id { get; set; }
private Project? project;
private PackResult? packResult;
private PackingSummary? summary;
private bool loading = true;
private bool CanOptimize => project != null &&
project.Parts.Count > 0 &&
project.StockBins.Count > 0 &&
project.CuttingToolId != null;
protected override async Task OnInitializedAsync()
{
project = await ProjectService.GetByIdAsync(Id);
if (project != null && CanOptimize)
{
var kerf = project.CuttingTool?.KerfInches ?? 0.125m;
packResult = PackingService.Pack(project.Parts, project.StockBins, kerf);
summary = PackingService.GetSummary(packResult);
}
loading = false;
}
private async Task PrintReport()
{
await JS.InvokeVoidAsync("window.print");
}
}

View File

@@ -0,0 +1,328 @@
@page "/suppliers/new"
@page "/suppliers/{Id:int}"
@inject SupplierService SupplierService
@inject MaterialService MaterialService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
<PageTitle>@(IsNew ? "Add Supplier" : "Edit Supplier")</PageTitle>
<h1>@(IsNew ? "Add Supplier" : supplier.Name)</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">Supplier Details</h5>
</div>
<div class="card-body">
<EditForm Model="supplier" OnValidSubmit="SaveSupplierAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="supplier.Name" />
<ValidationMessage For="@(() => supplier.Name)" />
</div>
<div class="mb-3">
<label class="form-label">Contact Info</label>
<InputTextArea class="form-control" @bind-Value="supplier.ContactInfo" rows="2" placeholder="Phone, email, address..." />
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="supplier.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(supplierErrorMessage))
{
<div class="alert alert-danger">@supplierErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingSupplier">
@if (savingSupplier)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Supplier" : "Save Changes")
</button>
<a href="suppliers" 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">Stock Lengths</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddStockForm">Add Stock</button>
</div>
<div class="card-body">
@if (showStockForm)
{
<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-6">
<label class="form-label">Material</label>
<select class="form-select" @bind="newStock.MaterialId">
<option value="0">-- Select Material --</option>
@foreach (var material in materials)
{
<option value="@material.Id">@material.DisplayName</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-6">
<label class="form-label">Price (optional)</label>
<InputNumber class="form-control" @bind-Value="newStock.Price" placeholder="0.00" />
</div>
<div class="col-md-6">
<label class="form-label">Notes</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>
}
@if (stocks.Count == 0)
{
<p class="text-muted">No stock lengths configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var stock in stocks)
{
<tr>
<td>@stock.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches)</td>
<td>@(stock.Price.HasValue ? stock.Price.Value.ToString("C") : "-")</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>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteStockDialog"
Title="Delete Stock Length"
Message="@deleteStockMessage"
ConfirmText="Delete"
OnConfirm="DeleteStockConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private Supplier supplier = new();
private List<SupplierStock> stocks = new();
private List<Material> materials = new();
private bool loading = true;
private bool savingSupplier;
private bool savingStock;
private string? supplierErrorMessage;
private string? stockErrorMessage;
private bool showStockForm;
private SupplierStock newStock = new();
private SupplierStock? editingStock;
private ConfirmDialog deleteStockDialog = null!;
private SupplierStock? stockToDelete;
private string deleteStockMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
if (Id.HasValue)
{
var existing = await SupplierService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("suppliers");
return;
}
supplier = existing;
stocks = await SupplierService.GetStocksForSupplierAsync(Id.Value);
}
loading = false;
}
private async Task SaveSupplierAsync()
{
supplierErrorMessage = null;
savingSupplier = true;
try
{
if (string.IsNullOrWhiteSpace(supplier.Name))
{
supplierErrorMessage = "Name is required";
return;
}
if (IsNew)
{
var created = await SupplierService.CreateAsync(supplier);
Navigation.NavigateTo($"suppliers/{created.Id}");
}
else
{
await SupplierService.UpdateAsync(supplier);
}
}
finally
{
savingSupplier = false;
}
}
private void ShowAddStockForm()
{
editingStock = null;
newStock = new SupplierStock { SupplierId = Id!.Value };
showStockForm = true;
stockErrorMessage = null;
}
private void EditStock(SupplierStock stock)
{
editingStock = stock;
newStock = new SupplierStock
{
Id = stock.Id,
SupplierId = stock.SupplierId,
MaterialId = stock.MaterialId,
LengthInches = stock.LengthInches,
Price = stock.Price,
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.MaterialId == 0)
{
stockErrorMessage = "Please select a material";
return;
}
if (newStock.LengthInches <= 0)
{
stockErrorMessage = "Length must be greater than zero";
return;
}
var exists = await SupplierService.StockExistsAsync(
newStock.SupplierId,
newStock.MaterialId,
newStock.LengthInches,
editingStock?.Id);
if (exists)
{
stockErrorMessage = "This stock length already exists for this material";
return;
}
if (editingStock == null)
{
await SupplierService.AddStockAsync(newStock);
}
else
{
await SupplierService.UpdateStockAsync(newStock);
}
stocks = await SupplierService.GetStocksForSupplierAsync(Id!.Value);
showStockForm = false;
editingStock = null;
}
finally
{
savingStock = false;
}
}
private void ConfirmDeleteStock(SupplierStock 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 SupplierService.DeleteStockAsync(stockToDelete.Id);
stocks = await SupplierService.GetStocksForSupplierAsync(Id!.Value);
}
}
}

View File

@@ -0,0 +1,91 @@
@page "/suppliers"
@inject SupplierService SupplierService
@inject NavigationManager Navigation
<PageTitle>Suppliers</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Suppliers</h1>
<a href="suppliers/new" class="btn btn-primary">Add Supplier</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (suppliers.Count == 0)
{
<div class="alert alert-info">
No suppliers found. <a href="suppliers/new">Add your first supplier</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Contact Info</th>
<th>Notes</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var supplier in suppliers)
{
<tr>
<td><a href="suppliers/@supplier.Id">@supplier.Name</a></td>
<td>@supplier.ContactInfo</td>
<td>@TruncateText(supplier.Notes, 50)</td>
<td>
<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>
</td>
</tr>
}
</tbody>
</table>
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Supplier"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Supplier> suppliers = new();
private bool loading = true;
private ConfirmDialog deleteDialog = null!;
private Supplier? supplierToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(Supplier supplier)
{
supplierToDelete = supplier;
deleteMessage = $"Are you sure you want to delete \"{supplier.Name}\"? This will also remove all stock lengths associated with this supplier.";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (supplierToDelete != null)
{
await SupplierService.DeleteAsync(supplierToDelete.Id);
suppliers = await SupplierService.GetAllAsync();
}
}
private string? TruncateText(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
return text;
return text.Substring(0, maxLength) + "...";
}
}

View File

@@ -0,0 +1,220 @@
@page "/tools"
@inject ProjectService ProjectService
@using CutList.Core.Formatting
<PageTitle>Cutting Tools</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Cutting Tools</h1>
<button class="btn btn-primary" @onclick="ShowAddForm">Add Tool</button>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
@if (showForm)
{
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">@(editingTool == null ? "Add Cutting Tool" : "Edit Cutting Tool")</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="formTool.Name" placeholder="e.g., Bandsaw" />
</div>
<div class="col-md-4">
<label class="form-label">Kerf Width (inches)</label>
<input type="number" step="0.0001" class="form-control" @bind="formTool.KerfInches" />
<div class="form-text">Common: 1/16" = 0.0625, 1/8" = 0.125</div>
</div>
<div class="col-md-4">
<label class="form-label">Default</label>
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="isDefault" @bind="formTool.IsDefault" />
<label class="form-check-label" for="isDefault">Set as default tool</label>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger mt-3">@errorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveAsync" disabled="@saving">
@if (saving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingTool == null ? "Add Tool" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
</div>
</div>
</div>
}
@if (tools.Count == 0)
{
<div class="alert alert-info">
No cutting tools found. Add your first cutting tool to get started.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Kerf Width</th>
<th>Default</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var tool in tools)
{
<tr>
<td>@tool.Name</td>
<td>@FormatKerf(tool.KerfInches)</td>
<td>
@if (tool.IsDefault)
{
<span class="badge bg-primary">Default</span>
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Cutting Tool"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<CuttingTool> tools = new();
private bool loading = true;
private bool showForm;
private bool saving;
private string? errorMessage;
private CuttingTool formTool = new();
private CuttingTool? editingTool;
private ConfirmDialog deleteDialog = null!;
private CuttingTool? toolToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
tools = await ProjectService.GetCuttingToolsAsync();
loading = false;
}
private void ShowAddForm()
{
editingTool = null;
formTool = new CuttingTool { KerfInches = 0.125m };
showForm = true;
errorMessage = null;
}
private void Edit(CuttingTool tool)
{
editingTool = tool;
formTool = new CuttingTool
{
Id = tool.Id,
Name = tool.Name,
KerfInches = tool.KerfInches,
IsDefault = tool.IsDefault,
IsActive = tool.IsActive
};
showForm = true;
errorMessage = null;
}
private void CancelForm()
{
showForm = false;
editingTool = null;
errorMessage = null;
}
private async Task SaveAsync()
{
errorMessage = null;
saving = true;
try
{
if (string.IsNullOrWhiteSpace(formTool.Name))
{
errorMessage = "Name is required";
return;
}
if (formTool.KerfInches < 0)
{
errorMessage = "Kerf width cannot be negative";
return;
}
if (editingTool == null)
{
await ProjectService.CreateCuttingToolAsync(formTool);
}
else
{
await ProjectService.UpdateCuttingToolAsync(formTool);
}
tools = await ProjectService.GetCuttingToolsAsync();
showForm = false;
editingTool = null;
}
finally
{
saving = false;
}
}
private void ConfirmDelete(CuttingTool tool)
{
toolToDelete = tool;
deleteMessage = $"Are you sure you want to delete \"{tool.Name}\"?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (toolToDelete != null)
{
await ProjectService.DeleteCuttingToolAsync(toolToDelete.Id);
tools = await ProjectService.GetCuttingToolsAsync();
}
}
private string FormatKerf(decimal kerf)
{
// Show as fraction if it's a common value
var inches = (double)kerf;
var formatted = FormatHelper.ConvertToMixedFraction(inches);
return $"{formatted}\" ({kerf:0.####}\")";
}
}