Compare commits

..

19 Commits

Author SHA1 Message Date
21d50e7c20 fix: Prevent shape change after material creation
Disable Shape dropdown on existing materials since changing shape would
require completely different dimension properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:19:01 -05:00
f723661696 feat: Convert part form to modal dialog and improve material ordering
- Replace inline part form with Bootstrap modal dialog for better UX
- Add SortOrder to material dropdown ordering in Parts and Stock tabs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:18:56 -05:00
c795c129e5 feat: Improve material ordering and default type in MCP tools
- Add SortOrder as secondary ordering key after Shape across all material
  queries (list_materials, search methods)
- Default material type to "Steel" when not specified in add_stock_with_offering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 00:18:51 -05:00
30071469bc chore: Add Claude Code project configuration
Add .claude directory with settings and memory files for
consistent AI-assisted development.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:33 -05:00
c9a2583f26 feat: Add MCP inventory management tools
Add comprehensive MCP tools for inventory management:
- list_suppliers, add_supplier
- list_materials, add_material, search_materials
- list_stock_items, add_stock_item
- list_supplier_offerings, add_supplier_offering
- add_stock_with_offering (convenience method)

Features:
- Dimension-based material search with tolerance
- Auto-generate size strings from dimensions
- Parse size strings to typed dimensions
- Type/Grade support for material categorization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:27 -05:00
0e5b63c557 refactor: Update controllers for new Material model
MaterialsController:
- Update to use MaterialShape enum
- Add Type and Grade to imports
- Fix display name formatting

SeedController:
- Update seed data to use MaterialShape enum
- Add MaterialType assignments

CuttingTool:
- Add Notes property

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:21 -05:00
6388e003d3 feat: Update UI for Jobs and enhanced Materials
Navigation:
- Rename Projects to Jobs in NavMenu
- Add new icon for multi-material boxes

Home page:
- Update references from Projects to Jobs

Materials pages:
- Add Type and Grade columns to index
- Shape-specific dimension editing with typed inputs
- Error handling with detailed messages

Stock pages:
- Show Shape, Type, Grade, Size columns
- Display QuantityOnHand with badges

Shared components:
- LengthInput: Add nullable binding mode for optional dimensions
- LengthInput: Format on blur for better UX
- CutListReport: Update for Job model references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:15 -05:00
c5da5dda98 feat: Update service layer for new data model
MaterialService:
- Include Dimensions in queries
- Add CreateWithDimensionsAsync for typed dimension creation
- Add UpdateWithDimensionsAsync with optional size regeneration
- Add dimension search methods by value with tolerance
- Sort by SortOrder for numeric ordering

StockItemService:
- Add stock transaction methods (AddStock, UseStock, AdjustStock)
- Add GetAverageCost and GetLastPurchasePrice for costing
- Add GetTransactionHistory for audit

CutListPackingService:
- Update to use JobPart instead of ProjectPart
- Support job-specific stock (JobStock) with priorities
- Fall back to all available stock when no job stock configured

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:38:06 -05:00
21cddb22c7 chore: Remove deprecated Project entities and pages
Remove files superseded by the Job model:
- Project, ProjectPart entities (replaced by Job, JobPart, JobStock)
- ProjectService (replaced by JobService)
- Projects UI pages (replaced by Jobs pages)
- MaterialStockLength entity (consolidated into StockItem)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:57 -05:00
3b036308c8 refactor: Update Material and StockItem entities
Material entity changes:
- Shape property now uses MaterialShape enum
- Add Type (MaterialType) and Grade properties
- Add SortOrder for numeric sorting
- Add Dimensions navigation property (1:1)
- Replace ProjectParts with JobParts collection

StockItem entity changes:
- Add QuantityOnHand for inventory tracking
- Add Notes field
- Add Transactions navigation property

DbContext updates:
- Configure MaterialDimensions TPH inheritance
- Add enum-to-string conversions for MaterialShape and MaterialType
- Configure shared column names for TPH properties
- Add indexes on primary dimension columns
- Update all entity relationships for Job model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:51 -05:00
4f6d986dc9 feat: Add material dimensions with typed properties
Implement TPH inheritance for material dimensions:
- MaterialShape enum with display names and parsing
- MaterialType enum (Steel, Aluminum, Stainless, etc.)
- MaterialDimensions base class with derived types per shape
- Auto-generate size strings from typed dimensions
- SortOrder field for numeric dimension sorting

Each shape has specific dimension properties:
- RoundBar: Diameter
- RoundTube: OuterDiameter, Wall
- FlatBar: Width, Thickness
- SquareBar/Tube: Size, Wall
- RectangularTube: Width, Height, Wall
- Angle: Leg1, Leg2, Thickness
- Channel: Height, Flange, Web
- IBeam: Height, WeightPerFoot
- Pipe: NominalSize, Wall, Schedule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:43 -05:00
254066c989 feat: Add stock transaction tracking system
- Add StockTransaction entity for audit trail
- Track received, used, adjusted, scrapped, and returned stock
- Include unit price tracking for cost analysis
- Link transactions to jobs and suppliers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:31 -05:00
ce14dd50cb refactor: Rename Project to Job with enhanced model
Rename the Project concept to Job for clarity:
- Add Job, JobPart, JobStock entities
- JobStock supports both inventory stock and custom lengths
- Add JobNumber field for job identification
- Add priority-based stock allocation for cut optimization
- Include Jobs UI pages (Index, Edit, Results)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:24 -05:00
dfc767320a fix: Improve architectural unit parsing and formatting
- Add fallback to parse plain decimal inches without unit symbols
- Fix fraction-only display to show "1/2" instead of "0-1/2"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:37:15 -05:00
5cc088ea6b feat: Add Stock Items UI and update Supplier offerings
- Add Stock Items index page listing all stock items
- Add Stock Items edit page with supplier offerings management
- Update Suppliers edit page to manage offerings (select from stock
  items instead of material+length)
- Add Stock Items navigation link to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:32 -05:00
6797d1e4fd feat: Update service layer for new stock model
- Add StockItemService for CRUD operations on stock items
- Update SupplierService to manage SupplierOfferings instead of
  SupplierStock (GetOfferingsForSupplierAsync, AddOfferingAsync, etc.)
- Update CutListPackingService to use StockItems for available lengths
- Register StockItemService in Program.cs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:25 -05:00
c4fc88f7d2 chore: Add migration for StockItem and SupplierOffering
Migration drops SupplierStocks table and creates StockItems and
SupplierOfferings tables with appropriate indexes and foreign keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:20 -05:00
9929d82768 refactor: Replace SupplierStock with StockItem/SupplierOffering model
- Remove SupplierStock entity
- Update Material navigation from SupplierStocks to StockItems
- Update Supplier navigation from Stocks to Offerings
- Update ApplicationDbContext with new DbSets and configurations
- Add unique constraints: StockItem(MaterialId, LengthInches) and
  SupplierOffering(SupplierId, StockItemId)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:14 -05:00
0ded77ce8b feat: Add StockItem and SupplierOffering entities
Introduce new data model that separates stock catalog (StockItem) from
supplier-specific pricing/catalog info (SupplierOffering). StockItem
represents a Material+Length combination, while SupplierOffering links
suppliers to stock items with part numbers, descriptions, and pricing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:32:07 -05:00
161 changed files with 15655 additions and 1373 deletions

BIN
.claude/mcp/Azure.Core.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
.claude/mcp/CutList.Mcp.dll Normal file

Binary file not shown.

BIN
.claude/mcp/CutList.Mcp.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

BIN
.claude/mcp/CutList.Web.dll Normal file

Binary file not shown.

BIN
.claude/mcp/CutList.Web.exe Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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,28 @@
{
"permissions": {
"allow": [
"Skill(roslyn-bridge)",
"Bash(dotnet build:*)",
"SlashCommand(/rb)",
"mcp__roslyn-bridge__get_projects",
"Bash(dotnet tool install:*)",
"Bash(dotnet ilspy:*)",
"Bash(dotnet add package:*)",
"Bash(git -C /c/Users/AJ/Desktop/Projects/CutList add CutList.Core/BinComparer.cs CutList.Core/BinItem.cs CutList.Core/MultiBin.cs CutList/Tool.cs)",
"Bash(git -C /c/Users/AJ/Desktop/Projects/CutList commit --amend --no-edit)",
"mcp__roslyn-bridge__get_code_smells",
"mcp__roslyn-bridge__get_duplicates",
"mcp__roslyn-bridge__get_code_smell_summary",
"mcp__cutlist__create_cutlist",
"Bash(dotnet run:*)",
"mcp__roslyn-bridge__get_files",
"mcp__roslyn-bridge__refresh_workspace",
"mcp__roslyn-bridge__get_diagnostics",
"Bash(dotnet ef database update:*)",
"mcp__roslyn-bridge__search_symbol",
"Bash(dotnet ef migrations add:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -28,7 +28,17 @@ namespace CutList.Core.Formatting
var match2 = regex.Match(input);
if (!match2.Success)
{
// If no unit symbols, try to parse as plain inches (e.g., "0.5" or "1/2" converted to "0.5")
if (!input.Contains("'") && !input.Contains("\""))
{
if (double.TryParse(input.Trim(), out var plainInches))
{
return Math.Round(plainInches, 8);
}
}
throw new Exception("Input is not in a valid format.");
}
var feet = match2.Groups["Feet"];
var inches = match2.Groups["Inches"];

View File

@@ -39,6 +39,12 @@ namespace CutList.Core.Formatting
return wholeNumber.ToString();
}
// If whole number is 0, just show the fraction
if (wholeNumber == 0)
{
return $"{numerator}/{denominator}";
}
return $"{wholeNumber}-{numerator}/{denominator}";
}

View File

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
<ProjectReference Include="..\CutList.Web\CutList.Web.csproj" />
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,15 @@
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
// Add DbContext for inventory tools
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"));
builder.Services
.AddMcpServer()
.WithStdioServerTransport()

View File

@@ -14,8 +14,8 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="projects">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Projects
<NavLink class="nav-link" href="jobs">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
</NavLink>
</div>
<div class="nav-item px-3">
@@ -23,6 +23,11 @@
<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="stock">
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
</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

View File

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

View File

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

View File

@@ -0,0 +1,822 @@
@page "/jobs/new"
@page "/jobs/{Id:int}"
@inject JobService JobService
@inject MaterialService MaterialService
@inject StockItemService StockItemService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
@if (!IsNew)
{
<a href="jobs/@Id/results" class="btn btn-success">Run Optimization</a>
}
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (IsNew)
{
<!-- New Job: Simple form -->
<div class="row">
<div class="col-lg-6">
@RenderDetailsForm()
</div>
</div>
}
else
{
<!-- Existing Job: Tabbed interface -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Details ? "active" : "")"
@onclick="() => SetTab(Tab.Details)" type="button">
Details
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Parts ? "active" : "")"
@onclick="() => SetTab(Tab.Parts)" type="button">
Parts
@if (job.Parts.Count > 0)
{
<span class="badge bg-secondary ms-1">@job.Parts.Sum(p => p.Quantity)</span>
}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Stock ? "active" : "")"
@onclick="() => SetTab(Tab.Stock)" type="button">
Stock
@if (job.Stock.Count > 0)
{
<span class="badge bg-secondary ms-1">@job.Stock.Count</span>
}
</button>
</li>
</ul>
<div class="tab-content">
@if (activeTab == Tab.Details)
{
<div class="row">
<div class="col-lg-6">
@RenderDetailsForm()
</div>
</div>
}
else if (activeTab == Tab.Parts)
{
@RenderPartsTab()
}
else if (activeTab == Tab.Stock)
{
@RenderStockTab()
}
</div>
}
@* Part Modal Dialog *@
@if (showPartForm)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingPart == null ? "Add Part" : "Edit Part")</h5>
<button type="button" class="btn-close" @onclick="CancelPartForm"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Shape</label>
<select class="form-select" @bind="selectedShape" @bind:after="OnShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Size</label>
<select class="form-select" @bind="newPart.MaterialId" disabled="@(!selectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in FilteredMaterials)
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newPart.LengthInches" />
</div>
<div class="col-md-6">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newPart.Quantity" min="1" />
</div>
<div class="col-12">
<label class="form-label">Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" @bind="newPart.Name" placeholder="Part name" />
</div>
</div>
@if (!string.IsNullOrEmpty(partErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@partErrorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelPartForm">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SavePartAsync">
@(editingPart == null ? "Add Part" : "Save Changes")
</button>
</div>
</div>
</div>
</div>
}
@code {
private enum Tab { Details, Parts, Stock }
[Parameter]
public int? Id { get; set; }
private Job job = new();
private List<Material> materials = new();
private List<CuttingTool> cuttingTools = new();
private bool loading = true;
private bool savingJob;
private string? jobErrorMessage;
private Tab activeTab = Tab.Details;
private void SetTab(Tab tab) => activeTab = tab;
// Parts form
private bool showPartForm;
private JobPart newPart = new();
private JobPart? editingPart;
private string? partErrorMessage;
private MaterialShape? selectedShape;
// Stock form
private bool showStockForm;
private bool showCustomStockForm;
private JobStock newStock = new();
private JobStock? editingStock;
private string? stockErrorMessage;
private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new();
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
? Enumerable.Empty<Material>()
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
cuttingTools = await JobService.GetCuttingToolsAsync();
if (Id.HasValue)
{
var existing = await JobService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("jobs");
return;
}
job = existing;
}
else
{
// Set default cutting tool for new jobs
var defaultTool = await JobService.GetDefaultCuttingToolAsync();
if (defaultTool != null)
{
job.CuttingToolId = defaultTool.Id;
}
}
loading = false;
}
private RenderFragment RenderDetailsForm() => __builder =>
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">Job Details</h5>
</div>
<div class="card-body">
<EditForm Model="job" OnValidSubmit="SaveJobAsync">
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">Job Number</label>
<input type="text" class="form-control" value="@job.JobNumber" readonly />
</div>
}
<div class="mb-3">
<label class="form-label">Job Name <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Name" placeholder="Descriptive name for this job" />
</div>
<div class="mb-3">
<label class="form-label">Customer <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Customer" placeholder="Customer name" />
</div>
<div class="mb-3">
<label class="form-label">Cutting Tool</label>
<InputSelect class="form-select" @bind-Value="job.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="job.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(jobErrorMessage))
{
<div class="alert alert-danger">@jobErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingJob">
@if (savingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Job" : "Save")
</button>
<a href="jobs" class="btn btn-outline-secondary">Back</a>
</div>
</EditForm>
</div>
</div>
};
private RenderFragment RenderPartsTab() => __builder =>
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Parts to Cut</h5>
<button class="btn btn-primary" @onclick="ShowAddPartForm">Add Part</button>
</div>
<div class="card-body">
@if (job.Parts.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No parts added yet.</p>
<p class="small">Add the parts you need to cut, selecting the material for each.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Name</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var part in job.Parts)
{
<tr>
<td>@part.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)part.LengthInches)</td>
<td>@part.Quantity</td>
<td>@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name)</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="mt-3 text-muted">
Total: @job.Parts.Sum(p => p.Quantity) pieces
</div>
}
</div>
</div>
};
private async Task SaveJobAsync()
{
jobErrorMessage = null;
savingJob = true;
try
{
if (IsNew)
{
var created = await JobService.CreateAsync(job);
Navigation.NavigateTo($"jobs/{created.Id}");
}
else
{
await JobService.UpdateAsync(job);
}
}
finally
{
savingJob = false;
}
}
// Parts methods
private void ShowAddPartForm()
{
editingPart = null;
newPart = new JobPart { JobId = Id!.Value, Quantity = 1 };
selectedShape = null;
showPartForm = true;
partErrorMessage = null;
}
private void OnShapeChanged()
{
newPart.MaterialId = 0;
}
private void EditPart(JobPart part)
{
editingPart = part;
newPart = new JobPart
{
Id = part.Id,
JobId = part.JobId,
MaterialId = part.MaterialId,
Name = part.Name,
LengthInches = part.LengthInches,
Quantity = part.Quantity,
SortOrder = part.SortOrder
};
selectedShape = part.Material?.Shape;
showPartForm = true;
partErrorMessage = null;
}
private void CancelPartForm()
{
showPartForm = false;
editingPart = null;
}
private async Task SavePartAsync()
{
partErrorMessage = null;
if (!selectedShape.HasValue)
{
partErrorMessage = "Please select a shape";
return;
}
if (newPart.MaterialId == 0)
{
partErrorMessage = "Please select a size";
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 JobService.AddPartAsync(newPart);
}
else
{
await JobService.UpdatePartAsync(newPart);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showPartForm = false;
editingPart = null;
}
private async Task DeletePart(JobPart part)
{
await JobService.DeletePartAsync(part.Id);
job = (await JobService.GetByIdAsync(Id!.Value))!;
}
// Stock tab
private RenderFragment RenderStockTab() => __builder =>
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock for This Job</h5>
<div class="btn-group">
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
</div>
<div class="card-body">
@if (showStockForm)
{
@RenderStockFromInventoryForm()
}
else if (showCustomStockForm)
{
@RenderCustomStockForm()
}
@if (job.Stock.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No stock configured for this job.</p>
<p class="small">Add stock from your inventory or define custom lengths.</p>
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
</div>
}
else
{
@RenderStockTable()
}
</div>
</div>
};
private RenderFragment RenderStockFromInventoryForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stock Length</label>
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
<option value="">-- Select --</option>
@foreach (var stock in availableStockItems)
{
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@stock.QuantityOnHand available)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Qty to Use</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
};
private RenderFragment RenderCustomStockForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-3">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
<small class="text-muted">Use -1 for unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveCustomStockAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
};
private RenderFragment RenderStockTable() => __builder =>
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Priority</th>
<th>Source</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var stock in job.Stock.OrderBy(s => s.Material?.Shape).ThenBy(s => s.Material?.Size).ThenBy(s => s.Priority))
{
<tr>
<td>@stock.Material?.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)stock.LengthInches)</td>
<td>@(stock.Quantity == -1 ? "Unlimited" : stock.Quantity.ToString())</td>
<td>@stock.Priority</td>
<td>
@if (stock.IsCustomLength)
{
<span class="badge bg-info">Custom</span>
}
else
{
<span class="badge bg-secondary">Inventory</span>
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
};
private void ShowAddStockFromInventory()
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = 1, Priority = 10 };
stockSelectedShape = null;
stockSelectedMaterialId = 0;
availableStockItems.Clear();
showStockForm = true;
showCustomStockForm = false;
stockErrorMessage = null;
}
private void ShowAddCustomStock()
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
stockSelectedShape = null;
showStockForm = false;
showCustomStockForm = true;
stockErrorMessage = null;
}
private void CancelStockForm()
{
showStockForm = false;
showCustomStockForm = false;
editingStock = null;
}
private async Task OnStockShapeChanged()
{
stockSelectedMaterialId = 0;
newStock.MaterialId = 0;
newStock.StockItemId = null;
availableStockItems.Clear();
}
private async Task OnStockMaterialChanged()
{
newStock.MaterialId = stockSelectedMaterialId;
newStock.StockItemId = null;
if (stockSelectedMaterialId > 0)
{
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
}
else
{
availableStockItems.Clear();
}
}
private void EditStock(JobStock stock)
{
editingStock = stock;
newStock = new JobStock
{
Id = stock.Id,
JobId = stock.JobId,
MaterialId = stock.MaterialId,
StockItemId = stock.StockItemId,
LengthInches = stock.LengthInches,
Quantity = stock.Quantity,
IsCustomLength = stock.IsCustomLength,
Priority = stock.Priority,
SortOrder = stock.SortOrder
};
stockSelectedShape = stock.Material?.Shape;
stockSelectedMaterialId = stock.MaterialId;
stockErrorMessage = null;
if (stock.IsCustomLength)
{
showStockForm = false;
showCustomStockForm = true;
}
else
{
showStockForm = true;
showCustomStockForm = false;
_ = OnStockMaterialChanged();
}
}
private async Task SaveStockFromInventoryAsync()
{
stockErrorMessage = null;
if (!stockSelectedShape.HasValue)
{
stockErrorMessage = "Please select a shape";
return;
}
if (stockSelectedMaterialId == 0)
{
stockErrorMessage = "Please select a size";
return;
}
if (!newStock.StockItemId.HasValue)
{
stockErrorMessage = "Please select a stock length";
return;
}
if (newStock.Quantity < 1)
{
stockErrorMessage = "Quantity must be at least 1";
return;
}
var selectedStock = availableStockItems.FirstOrDefault(s => s.Id == newStock.StockItemId);
if (selectedStock == null)
{
stockErrorMessage = "Selected stock not found";
return;
}
newStock.MaterialId = stockSelectedMaterialId;
newStock.LengthInches = selectedStock.LengthInches;
newStock.IsCustomLength = false;
if (editingStock == null)
{
await JobService.AddStockAsync(newStock);
}
else
{
await JobService.UpdateStockAsync(newStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false;
editingStock = null;
}
private async Task SaveCustomStockAsync()
{
stockErrorMessage = null;
if (!stockSelectedShape.HasValue)
{
stockErrorMessage = "Please select a shape";
return;
}
if (newStock.MaterialId == 0)
{
stockErrorMessage = "Please select a size";
return;
}
if (newStock.LengthInches <= 0)
{
stockErrorMessage = "Length must be greater than zero";
return;
}
if (newStock.Quantity < -1 || newStock.Quantity == 0)
{
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
return;
}
newStock.StockItemId = null;
newStock.IsCustomLength = true;
if (editingStock == null)
{
await JobService.AddStockAsync(newStock);
}
else
{
await JobService.UpdateStockAsync(newStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false;
editingStock = null;
}
private async Task DeleteStock(JobStock stock)
{
await JobService.DeleteStockAsync(stock.Id);
job = (await JobService.GetByIdAsync(Id!.Value))!;
}
}

View File

@@ -0,0 +1,120 @@
@page "/jobs"
@inject JobService JobService
@inject NavigationManager Navigation
<PageTitle>Jobs</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Jobs</h1>
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="QuickCreateJob" disabled="@creating">
@if (creating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Quick Create
</button>
<a href="jobs/new" class="btn btn-primary">New Job</a>
</div>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (jobs.Count == 0)
{
<div class="alert alert-info">
No jobs found. <a href="jobs/new">Create your first job</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Job #</th>
<th>Name</th>
<th>Customer</th>
<th>Cutting Tool</th>
<th>Last Modified</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var job in jobs)
{
<tr>
<td><a href="jobs/@job.Id">@job.JobNumber</a></td>
<td>@(job.Name ?? "-")</td>
<td>@(job.Customer ?? "-")</td>
<td>@(job.CuttingTool?.Name ?? "-")</td>
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
<td>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="jobs/@job.Id/results" class="btn btn-sm btn-success">Optimize</a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)">Copy</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Job"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Job> jobs = new();
private bool loading = true;
private bool creating = false;
private ConfirmDialog deleteDialog = null!;
private Job? jobToDelete;
private string deleteMessage = "";
protected override async Task OnInitializedAsync()
{
jobs = await JobService.GetAllAsync();
loading = false;
}
private async Task QuickCreateJob()
{
creating = true;
try
{
var job = await JobService.QuickCreateAsync();
Navigation.NavigateTo($"jobs/{job.Id}");
}
finally
{
creating = false;
}
}
private void ConfirmDelete(Job job)
{
jobToDelete = job;
deleteMessage = $"Are you sure you want to delete \"{job.DisplayName}\"? This will also delete all parts.";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (jobToDelete != null)
{
await JobService.DeleteAsync(jobToDelete.Id);
jobs = await JobService.GetAllAsync();
}
}
private async Task DuplicateJob(Job job)
{
var duplicate = await JobService.DuplicateAsync(job.Id);
Navigation.NavigateTo($"jobs/{duplicate.Id}");
}
}

Some files were not shown because too many files have changed in this diff Show More