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,66 @@
@if (IsVisible)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<p>@Message</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn @ConfirmButtonClass" @onclick="Confirm">@ConfirmText</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter]
public string Title { get; set; } = "Confirm";
[Parameter]
public string Message { get; set; } = "Are you sure?";
[Parameter]
public string ConfirmText { get; set; } = "Confirm";
[Parameter]
public string ConfirmButtonClass { get; set; } = "btn-danger";
[Parameter]
public EventCallback OnConfirm { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
public bool IsVisible { get; private set; }
public void Show()
{
IsVisible = true;
StateHasChanged();
}
public void Hide()
{
IsVisible = false;
StateHasChanged();
}
private async Task Confirm()
{
Hide();
await OnConfirm.InvokeAsync();
}
private async Task Cancel()
{
Hide();
await OnCancel.InvokeAsync();
}
}

View File

@@ -0,0 +1,88 @@
@using CutList.Core
@using CutList.Core.Nesting
@using CutList.Core.Formatting
@inject ReportService ReportService
<div class="cut-list-report">
<header class="report-header">
<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 (Project.Material != null)
{
<div class="meta-row"><span>Material:</span> @Project.Material.Shape - @Project.Material.Size</div>
}
@if (Project.CuttingTool != null)
{
<div class="meta-row"><span>Cut Method:</span> @Project.CuttingTool.Name (kerf: @Project.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>
</div>
</header>
@foreach (var (bin, index) in PackResult.Bins.Select((b, i) => (b, i + 1)))
{
<section class="bin-section">
<h2>BAR #@index - Length: @ReportService.FormatLength(bin.Length)</h2>
<table class="cuts-table">
<thead>
<tr>
<th style="width: 60px;">Qty</th>
<th style="width: 120px;">Length</th>
<th>Label</th>
</tr>
</thead>
<tbody>
@foreach (var group in ReportService.GroupItems(bin.Items))
{
<tr>
<td>@group.Count</td>
<td>@ReportService.FormatLength(group.Length)</td>
<td>@group.Name</td>
</tr>
}
</tbody>
</table>
<div class="drop">
Remaining drop: @ReportService.FormatLength(bin.RemainingLength)
(@((bin.Utilization * 100).ToString("F1"))% utilization)
</div>
</section>
}
<footer class="summary">
<h2>SUMMARY</h2>
<div class="summary-grid">
<div class="summary-row"><span>Stock Bars Needed:</span> <strong>@PackResult.Bins.Count</strong></div>
<div class="summary-row"><span>Total Pieces:</span> <strong>@TotalPieces</strong></div>
<div class="summary-row"><span>Total Material:</span> <strong>@ReportService.FormatLength(TotalMaterial)</strong></div>
<div class="summary-row"><span>Total Used:</span> <strong>@ReportService.FormatLength(TotalUsed)</strong></div>
<div class="summary-row"><span>Total Waste:</span> <strong>@ReportService.FormatLength(TotalWaste)</strong></div>
<div class="summary-row"><span>Efficiency:</span> <strong>@Efficiency.ToString("F1")%</strong></div>
</div>
</footer>
@if (!string.IsNullOrEmpty(Project.Notes))
{
<div class="notes-section">
<h3>Notes</h3>
<p>@Project.Notes</p>
</div>
}
</div>
@code {
[Parameter, EditorRequired]
public Project Project { get; set; } = null!;
[Parameter, EditorRequired]
public PackResult PackResult { get; set; } = null!;
private int TotalPieces => PackResult.Bins.Sum(b => b.Items.Count);
private double TotalMaterial => PackResult.Bins.Sum(b => b.Length);
private double TotalUsed => PackResult.Bins.Sum(b => b.UsedLength);
private double TotalWaste => PackResult.Bins.Sum(b => b.RemainingLength);
private double Efficiency => TotalMaterial > 0 ? TotalUsed / TotalMaterial * 100 : 0;
}

View File

@@ -0,0 +1,75 @@
@using CutList.Core.Formatting
<div class="length-input">
<input type="text"
class="form-control @(HasError ? "is-invalid" : "")"
value="@DisplayValue"
@onchange="OnInputChange"
placeholder="e.g., 12' 6&quot; or 144" />
@if (HasError)
{
<div class="invalid-feedback">@ErrorMessage</div>
}
</div>
@code {
[Parameter]
public decimal Value { get; set; }
[Parameter]
public EventCallback<decimal> ValueChanged { get; set; }
[Parameter]
public string? Placeholder { get; set; }
private string DisplayValue { get; set; } = string.Empty;
private bool HasError { get; set; }
private string ErrorMessage { get; set; } = string.Empty;
protected override void OnParametersSet()
{
if (Value > 0 && string.IsNullOrEmpty(DisplayValue))
{
DisplayValue = ArchUnits.FormatFromInches((double)Value);
}
}
private async Task OnInputChange(ChangeEventArgs e)
{
var input = e.Value?.ToString() ?? string.Empty;
DisplayValue = input;
HasError = false;
ErrorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
await ValueChanged.InvokeAsync(0);
return;
}
try
{
// Try to parse as architectural units
var inches = ArchUnits.ParseToInches(input);
await ValueChanged.InvokeAsync((decimal)inches);
}
catch
{
// Try to parse as plain decimal (inches)
if (decimal.TryParse(input, out var decimalValue))
{
await ValueChanged.InvokeAsync(decimalValue);
}
else
{
HasError = true;
ErrorMessage = "Invalid format. Use feet (12'), inches (6\"), or decimal (144)";
}
}
}
public static string FormatLength(decimal inches)
{
return ArchUnits.FormatFromInches((double)inches);
}
}