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:
21
CutList.Web/Components/App.razor
Normal file
21
CutList.Web/Components/App.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<link rel="stylesheet" href="css/report.css" />
|
||||
<link rel="stylesheet" href="CutList.Web.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
17
CutList.Web/Components/Layout/MainLayout.razor
Normal file
17
CutList.Web/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,17 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://github.com/yourusername/cutlist" target="_blank">CutList Web</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
37
CutList.Web/Components/Layout/NavMenu.razor
Normal file
37
CutList.Web/Components/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">CutList</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
</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>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="materials">
|
||||
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="suppliers">
|
||||
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tools">
|
||||
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
111
CutList.Web/Components/Layout/NavMenu.razor.css
Normal file
111
CutList.Web/Components/Layout/NavMenu.razor.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-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-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-check-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-list-check' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3.854 2.146a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 3.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 7.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 0 1 .708-.708l.146.147 1.146-1.147a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-box-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-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-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");
|
||||
}
|
||||
|
||||
.bi-tools-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-tools' viewBox='0 0 16 16'%3E%3Cpath d='M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242l-.914.305-.968-.968 2.617-2.654A3.003 3.003 0 0 0 13 0a3 3 0 1 0-.851 5.878L9.495 8.53l-2.675-2.675a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814L3.081 2.2 1 0ZM3 13a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm7.5-8.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
58
CutList.Web/Components/Pages/Home.razor
Normal file
58
CutList.Web/Components/Pages/Home.razor
Normal 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>
|
||||
133
CutList.Web/Components/Pages/Materials/Edit.razor
Normal file
133
CutList.Web/Components/Pages/Materials/Edit.razor
Normal 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" OD x 0.065 wall" />
|
||||
<ValidationMessage For="@(() => material.Size)" />
|
||||
<div class="form-text">Examples: "1" 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
CutList.Web/Components/Pages/Materials/Index.razor
Normal file
84
CutList.Web/Components/Pages/Materials/Index.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
503
CutList.Web/Components/Pages/Projects/Edit.razor
Normal file
503
CutList.Web/Components/Pages/Projects/Edit.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
94
CutList.Web/Components/Pages/Projects/Index.razor
Normal file
94
CutList.Web/Components/Pages/Projects/Index.razor
Normal 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}");
|
||||
}
|
||||
}
|
||||
142
CutList.Web/Components/Pages/Projects/Results.razor
Normal file
142
CutList.Web/Components/Pages/Projects/Results.razor
Normal 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");
|
||||
}
|
||||
}
|
||||
328
CutList.Web/Components/Pages/Suppliers/Edit.razor
Normal file
328
CutList.Web/Components/Pages/Suppliers/Edit.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
CutList.Web/Components/Pages/Suppliers/Index.razor
Normal file
91
CutList.Web/Components/Pages/Suppliers/Index.razor
Normal 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) + "...";
|
||||
}
|
||||
}
|
||||
220
CutList.Web/Components/Pages/Tools/Index.razor
Normal file
220
CutList.Web/Components/Pages/Tools/Index.razor
Normal 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.####}\")";
|
||||
}
|
||||
}
|
||||
6
CutList.Web/Components/Routes.razor
Normal file
6
CutList.Web/Components/Routes.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
66
CutList.Web/Components/Shared/ConfirmDialog.razor
Normal file
66
CutList.Web/Components/Shared/ConfirmDialog.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
88
CutList.Web/Components/Shared/CutListReport.razor
Normal file
88
CutList.Web/Components/Shared/CutListReport.razor
Normal 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;
|
||||
}
|
||||
75
CutList.Web/Components/Shared/LengthInput.razor
Normal file
75
CutList.Web/Components/Shared/LengthInput.razor
Normal 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" 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);
|
||||
}
|
||||
}
|
||||
14
CutList.Web/Components/_Imports.razor
Normal file
14
CutList.Web/Components/_Imports.razor
Normal file
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using CutList.Web
|
||||
@using CutList.Web.Components
|
||||
@using CutList.Web.Components.Shared
|
||||
@using CutList.Web.Data
|
||||
@using CutList.Web.Data.Entities
|
||||
@using CutList.Web.Services
|
||||
Reference in New Issue
Block a user