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,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>

View 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>

View 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>

View 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;
}
}

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.####}\")";
}
}

View 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>

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);
}
}

View 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

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,124 @@
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Data;
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Material> Materials => Set<Material>();
public DbSet<Supplier> Suppliers => Set<Supplier>();
public DbSet<SupplierStock> SupplierStocks => Set<SupplierStock>();
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<ProjectPart> ProjectParts => Set<ProjectPart>();
public DbSet<ProjectStockBin> ProjectStockBins => Set<ProjectStockBin>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Material
modelBuilder.Entity<Material>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Shape).HasMaxLength(50).IsRequired();
entity.Property(e => e.Size).HasMaxLength(100).IsRequired();
entity.Property(e => e.Description).HasMaxLength(255);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
});
// Supplier
modelBuilder.Entity<Supplier>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.Property(e => e.ContactInfo).HasMaxLength(500);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
});
// SupplierStock
modelBuilder.Entity<SupplierStock>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.LengthInches).HasPrecision(10, 4);
entity.Property(e => e.Price).HasPrecision(10, 2);
entity.Property(e => e.Notes).HasMaxLength(255);
entity.HasOne(e => e.Supplier)
.WithMany(s => s.Stocks)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Material)
.WithMany(m => m.SupplierStocks)
.HasForeignKey(e => e.MaterialId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => new { e.SupplierId, e.MaterialId, e.LengthInches }).IsUnique();
});
// CuttingTool
modelBuilder.Entity<CuttingTool>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(50).IsRequired();
entity.Property(e => e.KerfInches).HasPrecision(6, 4);
});
// Project
modelBuilder.Entity<Project>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.Material)
.WithMany(m => m.Projects)
.HasForeignKey(e => e.MaterialId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.CuttingTool)
.WithMany(t => t.Projects)
.HasForeignKey(e => e.CuttingToolId)
.OnDelete(DeleteBehavior.SetNull);
});
// ProjectPart
modelBuilder.Entity<ProjectPart>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.Property(e => e.LengthInches).HasPrecision(10, 4);
entity.HasOne(e => e.Project)
.WithMany(p => p.Parts)
.HasForeignKey(e => e.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
// ProjectStockBin
modelBuilder.Entity<ProjectStockBin>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.LengthInches).HasPrecision(10, 4);
entity.HasOne(e => e.Project)
.WithMany(p => p.StockBins)
.HasForeignKey(e => e.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
// Seed default cutting tools
modelBuilder.Entity<CuttingTool>().HasData(
new CuttingTool { Id = 1, Name = "Bandsaw", KerfInches = 0.0625m, IsDefault = true, IsActive = true },
new CuttingTool { Id = 2, Name = "Chop Saw", KerfInches = 0.125m, IsDefault = false, IsActive = true },
new CuttingTool { Id = 3, Name = "Cold Cut Saw", KerfInches = 0.0625m, IsDefault = false, IsActive = true },
new CuttingTool { Id = 4, Name = "Hacksaw", KerfInches = 0.0625m, IsDefault = false, IsActive = true }
);
}
}

View File

@@ -0,0 +1,12 @@
namespace CutList.Web.Data.Entities;
public class CuttingTool
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal KerfInches { get; set; }
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<Project> Projects { get; set; } = new List<Project>();
}

View File

@@ -0,0 +1,17 @@
namespace CutList.Web.Data.Entities;
public class Material
{
public int Id { get; set; }
public string Shape { get; set; } = string.Empty;
public string Size { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public ICollection<SupplierStock> SupplierStocks { get; set; } = new List<SupplierStock>();
public ICollection<Project> Projects { get; set; } = new List<Project>();
public string DisplayName => $"{Shape} - {Size}";
}

View File

@@ -0,0 +1,17 @@
namespace CutList.Web.Data.Entities;
public class Project
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int? MaterialId { get; set; }
public int? CuttingToolId { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public Material? Material { get; set; }
public CuttingTool? CuttingTool { get; set; }
public ICollection<ProjectPart> Parts { get; set; } = new List<ProjectPart>();
public ICollection<ProjectStockBin> StockBins { get; set; } = new List<ProjectStockBin>();
}

View File

@@ -0,0 +1,13 @@
namespace CutList.Web.Data.Entities;
public class ProjectPart
{
public int Id { get; set; }
public int ProjectId { get; set; }
public string Name { get; set; } = string.Empty;
public decimal LengthInches { get; set; }
public int Quantity { get; set; } = 1;
public int SortOrder { get; set; }
public Project Project { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace CutList.Web.Data.Entities;
public class ProjectStockBin
{
public int Id { get; set; }
public int ProjectId { get; set; }
public decimal LengthInches { get; set; }
public int Quantity { get; set; } = -1;
public int Priority { get; set; } = 25;
public int SortOrder { get; set; }
public Project Project { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace CutList.Web.Data.Entities;
public class Supplier
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SupplierStock> Stocks { get; set; } = new List<SupplierStock>();
}

View File

@@ -0,0 +1,15 @@
namespace CutList.Web.Data.Entities;
public class SupplierStock
{
public int Id { get; set; }
public int SupplierId { get; set; }
public int MaterialId { get; set; }
public decimal LengthInches { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public Supplier Supplier { get; set; } = null!;
public Material Material { get; set; } = null!;
}

View File

@@ -0,0 +1,387 @@
// <auto-generated />
using System;
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CutList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260202024820_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<decimal>("KerfInches")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("CuttingTools");
b.HasData(
new
{
Id = 1,
IsActive = true,
IsDefault = true,
KerfInches = 0.0625m,
Name = "Bandsaw"
},
new
{
Id = 2,
IsActive = true,
IsDefault = false,
KerfInches = 0.125m,
Name = "Chop Saw"
},
new
{
Id = 3,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Cold Cut Saw"
},
new
{
Id = 4,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Hacksaw"
});
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Description")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Shape")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Size")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Materials");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("CuttingToolId")
.HasColumnType("int");
b.Property<int?>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("CuttingToolId");
b.HasIndex("MaterialId");
b.ToTable("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("ProjectId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("ProjectParts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectStockBin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("ProjectId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("ProjectStockBins");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId");
b.HasIndex("SupplierId", "MaterialId", "LengthInches")
.IsUnique();
b.ToTable("SupplierStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
.WithMany("Projects")
.HasForeignKey("CuttingToolId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("Projects")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CuttingTool");
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.HasOne("CutList.Web.Data.Entities.Project", "Project")
.WithMany("Parts")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectStockBin", b =>
{
b.HasOne("CutList.Web.Data.Entities.Project", "Project")
.WithMany("StockBins")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("SupplierStocks")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Stocks")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Navigation("Projects");
b.Navigation("SupplierStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Navigation("Parts");
b.Navigation("StockBins");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Stocks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,241 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CuttingTools",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
KerfInches = table.Column<decimal>(type: "decimal(6,4)", precision: 6, scale: 4, nullable: false),
IsDefault = table.Column<bool>(type: "bit", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CuttingTools", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Materials",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Shape = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Size = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Materials", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Suppliers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ContactInfo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()")
},
constraints: table =>
{
table.PrimaryKey("PK_Suppliers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
MaterialId = table.Column<int>(type: "int", nullable: true),
CuttingToolId = table.Column<int>(type: "int", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
table.ForeignKey(
name: "FK_Projects_CuttingTools_CuttingToolId",
column: x => x.CuttingToolId,
principalTable: "CuttingTools",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Projects_Materials_MaterialId",
column: x => x.MaterialId,
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "SupplierStocks",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SupplierId = table.Column<int>(type: "int", nullable: false),
MaterialId = table.Column<int>(type: "int", nullable: false),
LengthInches = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Price = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
Notes = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierStocks", x => x.Id);
table.ForeignKey(
name: "FK_SupplierStocks_Materials_MaterialId",
column: x => x.MaterialId,
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupplierStocks_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProjectParts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProjectId = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
LengthInches = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProjectParts", x => x.Id);
table.ForeignKey(
name: "FK_ProjectParts_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProjectStockBins",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProjectId = table.Column<int>(type: "int", nullable: false),
LengthInches = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
Priority = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProjectStockBins", x => x.Id);
table.ForeignKey(
name: "FK_ProjectStockBins_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "CuttingTools",
columns: new[] { "Id", "IsActive", "IsDefault", "KerfInches", "Name" },
values: new object[,]
{
{ 1, true, true, 0.0625m, "Bandsaw" },
{ 2, true, false, 0.125m, "Chop Saw" },
{ 3, true, false, 0.0625m, "Cold Cut Saw" },
{ 4, true, false, 0.0625m, "Hacksaw" }
});
migrationBuilder.CreateIndex(
name: "IX_ProjectParts_ProjectId",
table: "ProjectParts",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Projects_CuttingToolId",
table: "Projects",
column: "CuttingToolId");
migrationBuilder.CreateIndex(
name: "IX_Projects_MaterialId",
table: "Projects",
column: "MaterialId");
migrationBuilder.CreateIndex(
name: "IX_ProjectStockBins_ProjectId",
table: "ProjectStockBins",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_SupplierStocks_MaterialId",
table: "SupplierStocks",
column: "MaterialId");
migrationBuilder.CreateIndex(
name: "IX_SupplierStocks_SupplierId_MaterialId_LengthInches",
table: "SupplierStocks",
columns: new[] { "SupplierId", "MaterialId", "LengthInches" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ProjectParts");
migrationBuilder.DropTable(
name: "ProjectStockBins");
migrationBuilder.DropTable(
name: "SupplierStocks");
migrationBuilder.DropTable(
name: "Projects");
migrationBuilder.DropTable(
name: "Suppliers");
migrationBuilder.DropTable(
name: "CuttingTools");
migrationBuilder.DropTable(
name: "Materials");
}
}
}

View File

@@ -0,0 +1,384 @@
// <auto-generated />
using System;
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CutList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<decimal>("KerfInches")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("CuttingTools");
b.HasData(
new
{
Id = 1,
IsActive = true,
IsDefault = true,
KerfInches = 0.0625m,
Name = "Bandsaw"
},
new
{
Id = 2,
IsActive = true,
IsDefault = false,
KerfInches = 0.125m,
Name = "Chop Saw"
},
new
{
Id = 3,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Cold Cut Saw"
},
new
{
Id = 4,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Hacksaw"
});
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Description")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Shape")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Size")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Materials");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("CuttingToolId")
.HasColumnType("int");
b.Property<int?>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("CuttingToolId");
b.HasIndex("MaterialId");
b.ToTable("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("ProjectId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("ProjectParts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectStockBin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("ProjectId")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("ProjectStockBins");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId");
b.HasIndex("SupplierId", "MaterialId", "LengthInches")
.IsUnique();
b.ToTable("SupplierStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
.WithMany("Projects")
.HasForeignKey("CuttingToolId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("Projects")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CuttingTool");
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectPart", b =>
{
b.HasOne("CutList.Web.Data.Entities.Project", "Project")
.WithMany("Parts")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ProjectStockBin", b =>
{
b.HasOne("CutList.Web.Data.Entities.Project", "Project")
.WithMany("StockBins")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierStock", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("SupplierStocks")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Stocks")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Projects");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Navigation("Projects");
b.Navigation("SupplierStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Project", b =>
{
b.Navigation("Parts");
b.Navigation("StockBins");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Stocks");
});
#pragma warning restore 612, 618
}
}
}

39
CutList.Web/Program.cs Normal file
View File

@@ -0,0 +1,39 @@
using CutList.Web.Components;
using CutList.Web.Data;
using CutList.Web.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Add Entity Framework
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Add application services
builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -0,0 +1,22 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,65 @@
using CutList.Core;
using CutList.Core.Nesting;
using CutList.Web.Data.Entities;
namespace CutList.Web.Services;
public class CutListPackingService
{
public PackResult Pack(IEnumerable<ProjectPart> parts, IEnumerable<ProjectStockBin> stockBins, decimal kerfInches)
{
var engine = new MultiBinEngine();
engine.Spacing = (double)kerfInches;
engine.Strategy = PackingStrategy.AdvancedFit;
// Convert stock bins to MultiBin
var multiBins = stockBins
.Where(b => b.LengthInches > 0)
.Select(b => new MultiBin((double)b.LengthInches, b.Quantity, b.Priority))
.ToList();
engine.SetBins(multiBins);
// Convert parts to BinItem (expand quantity)
var items = parts
.SelectMany(p => Enumerable.Range(0, p.Quantity)
.Select(_ => new BinItem(p.Name, (double)p.LengthInches)))
.ToList();
return engine.Pack(items);
}
public PackingSummary GetSummary(PackResult result)
{
var summary = new PackingSummary();
foreach (var bin in result.Bins)
{
summary.TotalBins++;
summary.TotalMaterial += bin.Length;
summary.TotalUsed += bin.UsedLength;
summary.TotalWaste += bin.RemainingLength;
summary.TotalPieces += bin.Items.Count;
}
summary.ItemsNotPlaced = result.ItemsNotUsed.Count;
if (summary.TotalMaterial > 0)
{
summary.Efficiency = summary.TotalUsed / summary.TotalMaterial * 100;
}
return summary;
}
}
public class PackingSummary
{
public int TotalBins { get; set; }
public int TotalPieces { get; set; }
public double TotalMaterial { get; set; }
public double TotalUsed { get; set; }
public double TotalWaste { get; set; }
public double Efficiency { get; set; }
public int ItemsNotPlaced { get; set; }
}

View File

@@ -0,0 +1,79 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class MaterialService
{
private readonly ApplicationDbContext _context;
public static readonly string[] CommonShapes =
{
"Round Tube",
"Square Tube",
"Rectangular Tube",
"Angle",
"Channel",
"Flat Bar",
"Round Bar",
"Square Bar",
"I-Beam",
"Pipe"
};
public MaterialService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Material>> GetAllAsync(bool includeInactive = false)
{
var query = _context.Materials.AsQueryable();
if (!includeInactive)
{
query = query.Where(m => m.IsActive);
}
return await query.OrderBy(m => m.Shape).ThenBy(m => m.Size).ToListAsync();
}
public async Task<Material?> GetByIdAsync(int id)
{
return await _context.Materials.FindAsync(id);
}
public async Task<Material> CreateAsync(Material material)
{
material.CreatedAt = DateTime.UtcNow;
_context.Materials.Add(material);
await _context.SaveChangesAsync();
return material;
}
public async Task UpdateAsync(Material material)
{
material.UpdatedAt = DateTime.UtcNow;
_context.Materials.Update(material);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var material = await _context.Materials.FindAsync(id);
if (material != null)
{
material.IsActive = false;
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(string shape, string size, int? excludeId = null)
{
var query = _context.Materials.Where(m => m.Shape == shape && m.Size == size && m.IsActive);
if (excludeId.HasValue)
{
query = query.Where(m => m.Id != excludeId.Value);
}
return await query.AnyAsync();
}
}

View File

@@ -0,0 +1,321 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class ProjectService
{
private readonly ApplicationDbContext _context;
public ProjectService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Project>> GetAllAsync()
{
return await _context.Projects
.Include(p => p.Material)
.Include(p => p.CuttingTool)
.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt)
.ToListAsync();
}
public async Task<Project?> GetByIdAsync(int id)
{
return await _context.Projects
.Include(p => p.Material)
.Include(p => p.CuttingTool)
.Include(p => p.Parts.OrderBy(pt => pt.SortOrder))
.Include(p => p.StockBins.OrderBy(sb => sb.SortOrder))
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<Project> CreateAsync(Project project)
{
project.CreatedAt = DateTime.UtcNow;
_context.Projects.Add(project);
await _context.SaveChangesAsync();
return project;
}
public async Task UpdateAsync(Project project)
{
project.UpdatedAt = DateTime.UtcNow;
_context.Projects.Update(project);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var project = await _context.Projects.FindAsync(id);
if (project != null)
{
_context.Projects.Remove(project);
await _context.SaveChangesAsync();
}
}
public async Task<Project> DuplicateAsync(int id)
{
var original = await GetByIdAsync(id);
if (original == null)
{
throw new ArgumentException("Project not found", nameof(id));
}
var duplicate = new Project
{
Name = $"{original.Name} (Copy)",
MaterialId = original.MaterialId,
CuttingToolId = original.CuttingToolId,
Notes = original.Notes,
CreatedAt = DateTime.UtcNow
};
_context.Projects.Add(duplicate);
await _context.SaveChangesAsync();
// Copy parts
foreach (var part in original.Parts)
{
_context.ProjectParts.Add(new ProjectPart
{
ProjectId = duplicate.Id,
Name = part.Name,
LengthInches = part.LengthInches,
Quantity = part.Quantity,
SortOrder = part.SortOrder
});
}
// Copy stock bins
foreach (var bin in original.StockBins)
{
_context.ProjectStockBins.Add(new ProjectStockBin
{
ProjectId = duplicate.Id,
LengthInches = bin.LengthInches,
Quantity = bin.Quantity,
Priority = bin.Priority,
SortOrder = bin.SortOrder
});
}
await _context.SaveChangesAsync();
return duplicate;
}
// Parts management
public async Task<ProjectPart> AddPartAsync(ProjectPart part)
{
var maxOrder = await _context.ProjectParts
.Where(p => p.ProjectId == part.ProjectId)
.MaxAsync(p => (int?)p.SortOrder) ?? -1;
part.SortOrder = maxOrder + 1;
_context.ProjectParts.Add(part);
await _context.SaveChangesAsync();
// Update project timestamp
var project = await _context.Projects.FindAsync(part.ProjectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
return part;
}
public async Task UpdatePartAsync(ProjectPart part)
{
_context.ProjectParts.Update(part);
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(part.ProjectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task DeletePartAsync(int id)
{
var part = await _context.ProjectParts.FindAsync(id);
if (part != null)
{
var projectId = part.ProjectId;
_context.ProjectParts.Remove(part);
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(projectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
// Stock bins management
public async Task<ProjectStockBin> AddStockBinAsync(ProjectStockBin bin)
{
var maxOrder = await _context.ProjectStockBins
.Where(b => b.ProjectId == bin.ProjectId)
.MaxAsync(b => (int?)b.SortOrder) ?? -1;
bin.SortOrder = maxOrder + 1;
_context.ProjectStockBins.Add(bin);
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(bin.ProjectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
return bin;
}
public async Task UpdateStockBinAsync(ProjectStockBin bin)
{
_context.ProjectStockBins.Update(bin);
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(bin.ProjectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task DeleteStockBinAsync(int id)
{
var bin = await _context.ProjectStockBins.FindAsync(id);
if (bin != null)
{
var projectId = bin.ProjectId;
_context.ProjectStockBins.Remove(bin);
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(projectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
public async Task ImportStockFromSupplierAsync(int projectId, int supplierId, int? materialId = null)
{
var query = _context.SupplierStocks
.Where(s => s.SupplierId == supplierId && s.IsActive);
if (materialId.HasValue)
{
query = query.Where(s => s.MaterialId == materialId.Value);
}
var stocks = await query.ToListAsync();
var maxOrder = await _context.ProjectStockBins
.Where(b => b.ProjectId == projectId)
.MaxAsync(b => (int?)b.SortOrder) ?? -1;
foreach (var stock in stocks)
{
// Check if already exists
var exists = await _context.ProjectStockBins
.AnyAsync(b => b.ProjectId == projectId && b.LengthInches == stock.LengthInches);
if (!exists)
{
_context.ProjectStockBins.Add(new ProjectStockBin
{
ProjectId = projectId,
LengthInches = stock.LengthInches,
Quantity = -1,
Priority = 25,
SortOrder = ++maxOrder
});
}
}
await _context.SaveChangesAsync();
var project = await _context.Projects.FindAsync(projectId);
if (project != null)
{
project.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
// Cutting tools
public async Task<List<CuttingTool>> GetCuttingToolsAsync(bool includeInactive = false)
{
var query = _context.CuttingTools.AsQueryable();
if (!includeInactive)
{
query = query.Where(t => t.IsActive);
}
return await query.OrderBy(t => t.Name).ToListAsync();
}
public async Task<CuttingTool?> GetCuttingToolByIdAsync(int id)
{
return await _context.CuttingTools.FindAsync(id);
}
public async Task<CuttingTool?> GetDefaultCuttingToolAsync()
{
return await _context.CuttingTools.FirstOrDefaultAsync(t => t.IsDefault && t.IsActive);
}
public async Task<CuttingTool> CreateCuttingToolAsync(CuttingTool tool)
{
if (tool.IsDefault)
{
// Clear other defaults
var others = await _context.CuttingTools.Where(t => t.IsDefault).ToListAsync();
foreach (var other in others)
{
other.IsDefault = false;
}
}
_context.CuttingTools.Add(tool);
await _context.SaveChangesAsync();
return tool;
}
public async Task UpdateCuttingToolAsync(CuttingTool tool)
{
if (tool.IsDefault)
{
var others = await _context.CuttingTools.Where(t => t.IsDefault && t.Id != tool.Id).ToListAsync();
foreach (var other in others)
{
other.IsDefault = false;
}
}
_context.CuttingTools.Update(tool);
await _context.SaveChangesAsync();
}
public async Task DeleteCuttingToolAsync(int id)
{
var tool = await _context.CuttingTools.FindAsync(id);
if (tool != null)
{
tool.IsActive = false;
await _context.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,35 @@
using CutList.Core;
using CutList.Core.Formatting;
using CutList.Core.Nesting;
using CutList.Web.Data.Entities;
namespace CutList.Web.Services;
public class ReportService
{
public string FormatLength(double inches)
{
return ArchUnits.FormatFromInches(inches);
}
public List<ItemGroup> GroupItems(IReadOnlyList<BinItem> items)
{
return items
.GroupBy(i => new { i.Name, i.Length })
.Select(g => new ItemGroup
{
Name = g.Key.Name,
Length = g.Key.Length,
Count = g.Count()
})
.OrderByDescending(g => g.Length)
.ToList();
}
}
public class ItemGroup
{
public string Name { get; set; } = string.Empty;
public double Length { get; set; }
public int Count { get; set; }
}

View File

@@ -0,0 +1,126 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class SupplierService
{
private readonly ApplicationDbContext _context;
public SupplierService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<Supplier>> GetAllAsync(bool includeInactive = false)
{
var query = _context.Suppliers.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query.OrderBy(s => s.Name).ToListAsync();
}
public async Task<Supplier?> GetByIdAsync(int id)
{
return await _context.Suppliers
.Include(s => s.Stocks)
.ThenInclude(st => st.Material)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<Supplier> CreateAsync(Supplier supplier)
{
supplier.CreatedAt = DateTime.UtcNow;
_context.Suppliers.Add(supplier);
await _context.SaveChangesAsync();
return supplier;
}
public async Task UpdateAsync(Supplier supplier)
{
_context.Suppliers.Update(supplier);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var supplier = await _context.Suppliers.FindAsync(id);
if (supplier != null)
{
supplier.IsActive = false;
await _context.SaveChangesAsync();
}
}
// Stock management
public async Task<List<SupplierStock>> GetStocksForSupplierAsync(int supplierId)
{
return await _context.SupplierStocks
.Include(s => s.Material)
.Where(s => s.SupplierId == supplierId && s.IsActive)
.OrderBy(s => s.Material.Shape)
.ThenBy(s => s.Material.Size)
.ThenBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<List<SupplierStock>> GetStocksForMaterialAsync(int materialId)
{
return await _context.SupplierStocks
.Include(s => s.Supplier)
.Where(s => s.MaterialId == materialId && s.IsActive && s.Supplier.IsActive)
.OrderBy(s => s.Supplier.Name)
.ThenBy(s => s.LengthInches)
.ToListAsync();
}
public async Task<SupplierStock?> GetStockByIdAsync(int id)
{
return await _context.SupplierStocks
.Include(s => s.Material)
.Include(s => s.Supplier)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<SupplierStock> AddStockAsync(SupplierStock stock)
{
_context.SupplierStocks.Add(stock);
await _context.SaveChangesAsync();
return stock;
}
public async Task UpdateStockAsync(SupplierStock stock)
{
_context.SupplierStocks.Update(stock);
await _context.SaveChangesAsync();
}
public async Task DeleteStockAsync(int id)
{
var stock = await _context.SupplierStocks.FindAsync(id);
if (stock != null)
{
stock.IsActive = false;
await _context.SaveChangesAsync();
}
}
public async Task<bool> StockExistsAsync(int supplierId, int materialId, decimal lengthInches, int? excludeId = null)
{
var query = _context.SupplierStocks.Where(s =>
s.SupplierId == supplierId &&
s.MaterialId == materialId &&
s.LengthInches == lengthInches &&
s.IsActive);
if (excludeId.HasValue)
{
query = query.Where(s => s.Id != excludeId.Value);
}
return await query.AnyAsync();
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}

View File

@@ -0,0 +1,179 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjswNiA1Mi4yNjg4TDI5MS4xMDUgODUuMjEzOEMyOTIuMTA0IDg2LjYwMDUgMjkxLjgwNyA4OC41NDA4IDI5MC40MjEgODkuNTM5N0wyNjguMTc2IDk5Ljk0MzlDMjY2Ljc5IDEwMC45NDMgMjY0Ljg0OSAxMDAuNjQ1IDI2My44NDkgOTkuMjU5NEwyMzkuNTcyIDY2LjMwNjVDMjM4LjU3MiA2NC45MiAyMzguODY5IDYyLjk3OTcgMjQwLjI1NiA2MS45ODA0TDI2MC44MDYgNTEuNjM5OUMyNjEuNjAyIDUxLjE4MzggMjYyLjU1IDUxLjAwMzYgMjYzLjUwNiA1MVoiIGZpbGw9IiNGRjgwODAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjxwYXRoIGQ9Ik0yNjMuMzQzIDY1LjM5OEMyNjQuMjY4IDY1LjM5OCAyNjUuMDA4IDY2LjEzOCAyNjUuMDA4IDY3LjA2M0MyNjUuMDA4IDY3Ljk4OCAyNjQuMjY4IDY4LjcyOCAyNjMuMzQzIDY4LjcyOEMyNjIuNDE4IDY4LjcyOCAyNjEuNjc4IDY3Ljk4OCAyNjEuNjc4IDY3LjA2M0MyNjEuNjc4IDY2LjEzOCAyNjIuNDE4IDY1LjM5OCAyNjMuMzQzIDY1LjM5OFoiIGZpbGw9IiNGRkZGRkYiLz48cGF0aCBkPSJNMjYzLjM0MyA3MS41OTEyQzI2My45NSA3MS41OTEyIDI2NC40NDQgNzIuMDg1NSAyNjQuNDQ0IDcyLjY5MjVMMjY0LjQ0NCA4NS4zMTg1QzI2NC40NDQgODUuOTI1NiAyNjMuOTUgODYuNDE5OCAyNjMuMzQzIDg2LjQxOTgiLz48L2c+PC9zdmc+) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
/* Custom styles for length input */
.length-input {
position: relative;
}
/* Table improvements */
.table th {
white-space: nowrap;
}
/* Card improvements */
.card-header {
background-color: #f8f9fa;
}
/* Form improvements */
.form-label {
font-weight: 500;
margin-bottom: 0.25rem;
}
.form-text {
font-size: 0.8rem;
}
/* Better mobile responsiveness for tables */
@media (max-width: 768px) {
.table-responsive-stack td,
.table-responsive-stack th {
display: block;
width: 100%;
text-align: left;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,200 @@
/* CutList Report Styles - Print Friendly */
.cut-list-report {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.report-header {
margin-bottom: 2rem;
border-bottom: 2px solid #333;
padding-bottom: 1rem;
}
.report-header h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #333;
}
.meta-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.meta-row {
font-size: 0.95rem;
}
.meta-row span:first-child {
font-weight: 600;
min-width: 120px;
display: inline-block;
color: #555;
}
.bin-section {
border: 1px solid #ccc;
border-radius: 4px;
margin: 1rem 0;
padding: 1rem;
page-break-inside: avoid;
break-inside: avoid;
}
.bin-section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
color: #333;
}
.cuts-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.75rem;
}
.cuts-table th,
.cuts-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.cuts-table th {
background: #f5f5f5;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
color: #666;
}
.cuts-table tbody tr:hover {
background-color: #f9f9f9;
}
.drop {
font-size: 0.9rem;
color: #666;
font-style: italic;
}
.summary {
background: #f0f0f0;
padding: 1.5rem;
margin-top: 2rem;
border-radius: 4px;
page-break-inside: avoid;
}
.summary h2 {
font-size: 1.2rem;
margin: 0 0 1rem 0;
color: #333;
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 2rem;
}
.summary-row {
display: flex;
justify-content: space-between;
}
.summary-row span {
color: #555;
}
.summary-row strong {
color: #333;
}
.notes-section {
margin-top: 2rem;
padding: 1rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
}
.notes-section h3 {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: #555;
}
.notes-section p {
margin: 0;
white-space: pre-wrap;
}
/* Print styles */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.sidebar,
.top-row,
.page > main > .top-row,
.btn,
button,
nav,
.navbar {
display: none !important;
}
.page {
display: block !important;
}
.page > main {
margin-left: 0 !important;
padding: 0 !important;
}
.content {
padding: 0 !important;
}
.cut-list-report {
max-width: 100%;
padding: 0;
margin: 0;
}
.bin-section {
break-inside: avoid;
page-break-inside: avoid;
}
.summary {
break-inside: avoid;
page-break-inside: avoid;
}
.alert {
display: none !important;
}
.card {
display: none !important;
}
h1:not(.report-header h1) {
display: none !important;
}
.text-muted:not(.cut-list-report .text-muted) {
display: none !important;
}
}

View File

@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core", "CutList.Cor
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\CutList.Mcp.csproj", "{3B53377F-E012-42BA-82C8-322815D661B3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -55,6 +57,18 @@ Global
{3B53377F-E012-42BA-82C8-322815D661B3}.Release|x64.Build.0 = Release|Any CPU
{3B53377F-E012-42BA-82C8-322815D661B3}.Release|x86.ActiveCfg = Release|Any CPU
{3B53377F-E012-42BA-82C8-322815D661B3}.Release|x86.Build.0 = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|x64.ActiveCfg = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|x64.Build.0 = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|x86.ActiveCfg = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Debug|x86.Build.0 = Debug|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|Any CPU.Build.0 = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.ActiveCfg = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE