Compare commits
28
Commits
ec52834cd4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf7f1170e | ||
|
|
9b1564451d | ||
|
|
5409479e00 | ||
|
|
25af9e3ba8 | ||
|
|
3ccaddef0c | ||
|
|
79bf56afd8 | ||
|
|
96f5824d12 | ||
|
|
db054aa6dc | ||
|
|
8e2027d81b | ||
|
|
57c7aa5f9f | ||
|
|
34c9b77501 | ||
|
|
203ea0c69d | ||
|
|
5cbd06711c | ||
|
|
f3b911f37c | ||
|
|
1f04c5343a | ||
|
|
3fbcb05d4d | ||
|
|
4f21d536f8 | ||
|
|
c1409bbdfb | ||
|
|
7882ed7669 | ||
|
|
af37789dcd | ||
|
|
ebe725579b | ||
|
|
05179aa445 | ||
|
|
ec16dd2c9b | ||
|
|
0c5ecf716a | ||
|
|
c66099481b | ||
|
|
9020ba80c1 | ||
|
|
2cb9c5ec8d | ||
|
|
232b86f15b |
+4
-5
@@ -244,13 +244,12 @@ ModelManifest.xml
|
|||||||
# FAKE - F# Make
|
# FAKE - F# Make
|
||||||
.fake/
|
.fake/
|
||||||
|
|
||||||
# AlroCatalog scraper output
|
|
||||||
scripts/AlroCatalog/__pycache__/
|
|
||||||
scripts/AlroCatalog/pdfs/
|
|
||||||
scripts/AlroCatalog/screenshots/
|
|
||||||
|
|
||||||
# Superpowers subagent-driven-development scratch workspace
|
# Superpowers subagent-driven-development scratch workspace
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
# Playwright MCP browser session logs/traces
|
# Playwright MCP browser session logs/traces
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Python bytecode cache (tests/)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -12,9 +12,9 @@ The solution contains four projects:
|
|||||||
|
|
||||||
| Project | Framework | Purpose |
|
| Project | Framework | Purpose |
|
||||||
|---------|-----------|---------|
|
|---------|-----------|---------|
|
||||||
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
|
| **CutList** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||||
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
| **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
||||||
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
| **CutList.Web** | .NET 10.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
||||||
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
|
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
|
||||||
|
|
||||||
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
|
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
|
||||||
@@ -97,6 +97,14 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `
|
|||||||
|
|
||||||
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
|
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
|
||||||
|
|
||||||
|
**Table actions**: Every row-level action cell uses the shared `table-actions` class from `wwwroot/css/app.css`. It is an `inline-flex` non-wrapping control group, so Edit/Delete/Copy buttons remain side by side and do not increase table-row height.
|
||||||
|
|
||||||
|
**Job title**: The job-editor page uses the `job-title` class to keep long job names at a compact, readable heading size without changing the larger dashboard hero typography.
|
||||||
|
|
||||||
|
**Overview**: The root page uses `OverviewService` to show recently created jobs, headline planning counts, and the five stock configurations most frequently specified across job stock. The stock ranking is a planning-demand signal (distinct jobs configured for a material/length), not a count of on-hand inventory.
|
||||||
|
|
||||||
|
**Material list semantics**: The Results tab labels lengths not covered by the job's configured stock as a **Material List**, not a purchase list. It identifies required material; purchasing remains a separate decision outside the cut-list result.
|
||||||
|
|
||||||
### CutList.Mcp — MCP Server
|
### CutList.Mcp — MCP Server
|
||||||
|
|
||||||
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
|
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
|
||||||
@@ -171,7 +179,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
|
|
||||||
### CatalogService
|
### CatalogService
|
||||||
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
|
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
|
||||||
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` / `scripts/AlroCatalog` data-loading workflows
|
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` data-loading workflow
|
||||||
|
|
||||||
## CutList.Web Pages
|
## CutList.Web Pages
|
||||||
|
|
||||||
@@ -208,7 +216,8 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
||||||
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
||||||
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
|
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
|
||||||
- **Job lock flow** — Optimize job -> Lock Job (manual action, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
- **Job lock flow** — Optimize job -> review/print results -> Lock Job (manual action beside Print Report on the Results tab, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
||||||
|
- **Printed cut badges** — Print styles intentionally remove color fills; cut badges therefore force black text and a black part-number/length divider so both remain legible on paper. The print-only tool line shows the selected cut method and kerf immediately below the summary.
|
||||||
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
||||||
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
||||||
- **Priority system** — Lower priority bins used first in packing algorithm
|
- **Priority system** — Lower priority bins used first in packing algorithm
|
||||||
@@ -218,7 +227,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
|||||||
|
|
||||||
- `Deploy-CutListWeb.ps1` — publishes and installs CutList.Web as a Windows Service (see Build Commands above)
|
- `Deploy-CutListWeb.ps1` — publishes and installs CutList.Web as a Windows Service (see Build Commands above)
|
||||||
- `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`)
|
- `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`)
|
||||||
- `AlroCatalog/` — Python scraper (`scrape_alro.py`) for pulling material/size/grade data from the Alro Steel SmartGrid site into a JSON catalog for import; see `SCRAPE_PLAN.md` for scraper status and cascading-dropdown navigation notes. Not part of the .NET build.
|
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using CutList.Core.Formatting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace CutList.Core.Tests;
|
||||||
|
|
||||||
|
public class ResultsLengthDisplayTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(150, "150\"")]
|
||||||
|
[InlineData(150.375, "150-3/8\"")]
|
||||||
|
public void FormatInches_uses_total_inches_without_converting_to_feet(double inches, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ArchUnits.FormatInches(inches));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Results_markup_includes_unit_toggle_and_divider_between_part_and_length()
|
||||||
|
{
|
||||||
|
var sourcePath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/Components/Pages/Jobs/Edit.razor"));
|
||||||
|
var markup = File.ReadAllText(sourcePath);
|
||||||
|
|
||||||
|
Assert.Contains("FormatResultLength", markup);
|
||||||
|
Assert.Contains("cut-part-badge-divider", markup);
|
||||||
|
Assert.Contains("Show feet + inches", markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Printed_cut_list_rows_are_kept_together()
|
||||||
|
{
|
||||||
|
var sourcePath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/wwwroot/css/report.css"));
|
||||||
|
var css = File.ReadAllText(sourcePath);
|
||||||
|
|
||||||
|
Assert.Contains(".cutlist-material-card tbody tr", css);
|
||||||
|
Assert.Contains("break-inside: avoid", css);
|
||||||
|
Assert.Contains("page-break-inside: avoid", css);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,5 +80,13 @@ namespace CutList.Core.Formatting
|
|||||||
return $"{inches}\"";
|
return $"{inches}\"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a measurement as a mixed fraction of total inches without converting to feet.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatInches(double totalInches)
|
||||||
|
{
|
||||||
|
return $"{FormatHelper.ConvertToMixedFraction(totalInches)}\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
<link rel="stylesheet" href="css/report.css" />
|
<link rel="stylesheet" href="css/report.css" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<article class="content px-4">
|
<article class="content">
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row">
|
||||||
<div class="container-fluid">
|
<div class="brand-lockup">
|
||||||
<a class="navbar-brand" href="">CutList</a>
|
<a class="brand-mark" href="" aria-label="CutList home"><i class="bi bi-scissors"></i></a>
|
||||||
|
<a class="navbar-brand" href=""><span class="brand-cut">Cut</span><span class="brand-list">List</span><small>SHOP FLOOR EDITION</small></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -8,29 +9,31 @@
|
|||||||
|
|
||||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||||
<nav class="flex-column">
|
<nav class="flex-column">
|
||||||
|
<p class="nav-section-label">Workspace</p>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
<i class="bi bi-grid-1x2-fill" aria-hidden="true"></i> Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="jobs">
|
<NavLink class="nav-link" href="jobs">
|
||||||
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
|
<i class="bi bi-layers-fill" aria-hidden="true"></i> Jobs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="nav-section-label">Library</p>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="materials">
|
<NavLink class="nav-link" href="materials">
|
||||||
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
|
<i class="bi bi-box-seam-fill" aria-hidden="true"></i> Materials
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="stock">
|
<NavLink class="nav-link" href="stock">
|
||||||
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
|
<i class="bi bi-rulers" aria-hidden="true"></i> Stock Items
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="tools">
|
<NavLink class="nav-link" href="tools">
|
||||||
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
|
<i class="bi bi-tools" aria-hidden="true"></i> Cutting Tools
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,119 +1,20 @@
|
|||||||
.navbar-toggler {
|
.top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
|
||||||
appearance: none;
|
.brand-lockup { display: flex; align-items: center; gap: .75rem; }
|
||||||
cursor: pointer;
|
.brand-mark { align-items: center; background: #2b75a8; border-radius: .45rem; color: #fff; display: flex; font-size: 1rem; height: 2.25rem; justify-content: center; text-decoration: none; transform: rotate(-8deg); width: 2.25rem; }
|
||||||
width: 3.5rem;
|
.brand-mark i { transform: rotate(8deg); }
|
||||||
height: 2.5rem;
|
.navbar-brand { color: #17212b; font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.25rem; font-weight: 700; letter-spacing: -.035em; line-height: .95; text-decoration: none; }
|
||||||
color: white;
|
.navbar-brand .brand-cut { color: #2b75a8; }
|
||||||
position: absolute;
|
.navbar-brand .brand-list { color: #17212b; }
|
||||||
top: 0.5rem;
|
.navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
|
||||||
right: 1rem;
|
.nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
.nav-item { padding: .1rem .75rem; }
|
||||||
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);
|
.nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #c4d3dc; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
|
||||||
}
|
.nav-item ::deep .nav-link i { color: #8fa7b7; font-size: 1rem; width: 1.1rem; }
|
||||||
|
.nav-item ::deep .nav-link:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
.navbar-toggler:checked {
|
.nav-item ::deep a.active { background: #294655; color: #fff; }
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
.nav-item ::deep a.active i { color: #72b5df; }
|
||||||
}
|
.navbar-toggler { appearance: none; background: transparent; border: 1px solid #587382; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
|
||||||
|
.navbar-toggler::after { content: '☰'; }
|
||||||
.top-row {
|
.nav-scrollable { display: none; }
|
||||||
height: 3.5rem;
|
.navbar-toggler:checked ~ .nav-scrollable { display: block; }
|
||||||
background-color: rgba(0,0,0,0.4);
|
@media (min-width: 641px) { .navbar-toggler { display: none; } .nav-scrollable { display: block; } }
|
||||||
}
|
|
||||||
|
|
||||||
.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-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-cart-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-cart3' viewBox='0 0 16 16'%3E%3Cpath d='M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,49 +1,143 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@inject OverviewService OverviewService
|
||||||
|
@using CutList.Core.Formatting
|
||||||
|
|
||||||
<PageTitle>CutList - Home</PageTitle>
|
<PageTitle>CutList - Overview</PageTitle>
|
||||||
|
|
||||||
<h1>CutList</h1>
|
@if (loading)
|
||||||
|
{
|
||||||
|
<p><em>Loading overview…</em></p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="overview-header">
|
||||||
|
<div class="overview-actions">
|
||||||
|
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
|
||||||
|
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
|
@if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-4" role="alert">
|
||||||
|
<strong>Overview data is unavailable.</strong> @loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row mt-4">
|
<section class="overview-stats" aria-label="CutList statistics">
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
<a href="jobs" class="overview-stat">
|
||||||
<div class="card h-100">
|
<span class="overview-stat-label">Jobs</span>
|
||||||
<div class="card-body">
|
<strong>@overview.TotalJobs</strong>
|
||||||
<h5 class="card-title">Jobs</h5>
|
<small>@overview.OpenJobs open for planning</small>
|
||||||
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
|
</a>
|
||||||
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
|
<a href="jobs" class="overview-stat">
|
||||||
</div>
|
<span class="overview-stat-label">Parts required</span>
|
||||||
</div>
|
<strong>@overview.TotalParts</strong>
|
||||||
</div>
|
<small>Across all current jobs</small>
|
||||||
<div class="col-md-6 col-lg-4 mb-4">
|
</a>
|
||||||
<div class="card h-100">
|
<a href="stock" class="overview-stat">
|
||||||
<div class="card-body">
|
<span class="overview-stat-label">Stock options</span>
|
||||||
<h5 class="card-title">Materials</h5>
|
<strong>@overview.ActiveStockItems</strong>
|
||||||
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
|
<small>Active catalog lengths</small>
|
||||||
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
|
</a>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-lg-4 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" />
|
<section class="overview-grid">
|
||||||
|
<article class="overview-panel overview-recent-jobs">
|
||||||
|
<div class="overview-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Work queue</p>
|
||||||
|
<h2>Recently created jobs</h2>
|
||||||
|
</div>
|
||||||
|
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
@if (overview.RecentJobs.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="overview-empty">
|
||||||
|
<i class="bi bi-layers" aria-hidden="true"></i>
|
||||||
|
<p>No jobs yet. Create a job to begin building your planning history.</p>
|
||||||
|
<a href="jobs/new" class="btn btn-primary">Create first job</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="overview-job-list">
|
||||||
|
@foreach (var job in overview.RecentJobs)
|
||||||
|
{
|
||||||
|
<a href="jobs/@job.Id" class="overview-job-row">
|
||||||
|
<span class="overview-job-id">@job.JobNumber</span>
|
||||||
|
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
|
||||||
|
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
|
||||||
|
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
|
||||||
|
@if (job.IsLocked)
|
||||||
|
{
|
||||||
|
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
<h4>How It Works</h4>
|
<article class="overview-panel overview-stock-panel">
|
||||||
<ol>
|
<div class="overview-panel-heading">
|
||||||
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
|
<div>
|
||||||
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li>
|
<p class="eyebrow">Planning signal</p>
|
||||||
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
|
<h2>Frequently specified stock</h2>
|
||||||
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li>
|
</div>
|
||||||
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
|
<a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||||
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>
|
</div>
|
||||||
|
<p class="overview-panel-note">Based on stock configured on jobs. This shows recurring planning demand, not on-hand inventory.</p>
|
||||||
|
@if (overview.FrequentStock.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="overview-empty overview-empty-compact">
|
||||||
|
<i class="bi bi-box-seam" aria-hidden="true"></i>
|
||||||
|
<p>Add stock to jobs to reveal the configurations used most often.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ol class="overview-stock-list">
|
||||||
|
@for (var stockIndex = 0; stockIndex < overview.FrequentStock.Count; stockIndex++)
|
||||||
|
{
|
||||||
|
var stock = overview.FrequentStock[stockIndex];
|
||||||
|
<li>
|
||||||
|
<span class="overview-stock-rank">@((stockIndex + 1).ToString("D2"))</span>
|
||||||
|
<div>
|
||||||
|
<strong>@stock.MaterialName</strong>
|
||||||
|
<span>@ArchUnits.FormatFromInches((double)stock.LengthInches) stock length</span>
|
||||||
|
</div>
|
||||||
|
<span class="overview-stock-count">@stock.JobCount <small>@(stock.JobCount == 1 ? "job" : "jobs")</small></span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
</ol>
|
</ol>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private OverviewSnapshot overview = new(0, 0, 0, 0, [], []);
|
||||||
|
private bool loading = true;
|
||||||
|
private string? loadError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
overview = await OverviewService.GetSnapshotAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = $"The dashboard could not connect to its database ({ex.GetType().Name}).";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
<h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||||
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!IsNew && job.IsLocked)
|
@if (!IsNew && job.IsLocked)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-lock-fill me-2"></i>
|
<i class="bi bi-lock-fill me-2"></i>
|
||||||
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
||||||
@@ -229,17 +229,40 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Import Stock Modal *@
|
@* Add/Edit Stock Modal *@
|
||||||
@if (showImportModal)
|
@if (showStockModal)
|
||||||
{
|
{
|
||||||
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
<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-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Import Stock from Inventory</h5>
|
<h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
|
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
@if (editingStock == null)
|
||||||
|
{
|
||||||
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
|
||||||
|
type="button" disabled="@(job.Parts.Count == 0)"
|
||||||
|
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
|
||||||
|
@onclick="() => stockModalTab = StockModalTab.Inventory">
|
||||||
|
From Inventory
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
|
||||||
|
type="button"
|
||||||
|
@onclick="() => stockModalTab = StockModalTab.Custom">
|
||||||
|
Custom Length
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||||
|
{
|
||||||
@if (loadingImport)
|
@if (loadingImport)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<div class="text-center py-4">
|
||||||
@@ -307,9 +330,109 @@ else
|
|||||||
{
|
{
|
||||||
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||||
|
{
|
||||||
|
<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)</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" />
|
||||||
|
<small class="text-muted">-1 = 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
|
||||||
|
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||||
|
{
|
||||||
@if (importCandidates.Count > 0)
|
@if (importCandidates.Count > 0)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
||||||
@@ -317,6 +440,17 @@ else
|
|||||||
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
||||||
|
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,18 +484,16 @@ else
|
|||||||
private int partSelectedMaterialId;
|
private int partSelectedMaterialId;
|
||||||
private List<PartRow> partRows = new();
|
private List<PartRow> partRows = new();
|
||||||
|
|
||||||
// Stock form
|
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
|
||||||
private bool showStockForm;
|
private enum StockModalTab { Inventory, Custom }
|
||||||
private bool showCustomStockForm;
|
private bool showStockModal;
|
||||||
|
private StockModalTab stockModalTab = StockModalTab.Inventory;
|
||||||
private JobStock newStock = new();
|
private JobStock newStock = new();
|
||||||
private JobStock? editingStock;
|
private JobStock? editingStock;
|
||||||
private string? stockErrorMessage;
|
private string? stockErrorMessage;
|
||||||
private MaterialShape? stockSelectedShape;
|
private MaterialShape? stockSelectedShape;
|
||||||
private int stockSelectedMaterialId;
|
private int stockSelectedMaterialId;
|
||||||
private List<StockItem> availableStockItems = new();
|
private List<StockItem> availableStockItems = new();
|
||||||
|
|
||||||
// Import modal
|
|
||||||
private bool showImportModal;
|
|
||||||
private bool loadingImport;
|
private bool loadingImport;
|
||||||
private List<ImportStockCandidate> importCandidates = new();
|
private List<ImportStockCandidate> importCandidates = new();
|
||||||
private string? importErrorMessage;
|
private string? importErrorMessage;
|
||||||
@@ -371,6 +503,7 @@ else
|
|||||||
private MultiMaterialPackingSummary? summary;
|
private MultiMaterialPackingSummary? summary;
|
||||||
private bool optimizing;
|
private bool optimizing;
|
||||||
private bool lockingJob;
|
private bool lockingJob;
|
||||||
|
private bool showLengthsInInches = true;
|
||||||
|
|
||||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||||
@@ -379,6 +512,9 @@ else
|
|||||||
|
|
||||||
private bool IsNew => !Id.HasValue;
|
private bool IsNew => !Id.HasValue;
|
||||||
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
|
||||||
|
private string FormatResultLength(double inches) => showLengthsInInches
|
||||||
|
? ArchUnits.FormatInches(inches)
|
||||||
|
: ArchUnits.FormatFromInches(inches);
|
||||||
|
|
||||||
private async Task UnlockJob()
|
private async Task UnlockJob()
|
||||||
{
|
{
|
||||||
@@ -554,8 +690,10 @@ else
|
|||||||
<td>
|
<td>
|
||||||
@if (!job.IsLocked)
|
@if (!job.IsLocked)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<div class="table-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -741,31 +879,16 @@ else
|
|||||||
<h5 class="mb-0">Stock for This Job</h5>
|
<h5 class="mb-0">Stock for This Job</h5>
|
||||||
@if (!job.IsLocked)
|
@if (!job.IsLocked)
|
||||||
{
|
{
|
||||||
<div class="d-flex gap-2">
|
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
|
||||||
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
|
|
||||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
|
||||||
Import from Inventory
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (showStockForm)
|
|
||||||
{
|
|
||||||
@RenderStockFromInventoryForm()
|
|
||||||
}
|
|
||||||
else if (showCustomStockForm)
|
|
||||||
{
|
|
||||||
@RenderCustomStockForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (job.Stock.Count == 0)
|
@if (job.Stock.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4 text-muted">
|
<div class="text-center py-4 text-muted">
|
||||||
<p class="mb-2">No stock configured for this job.</p>
|
<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">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>
|
<p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -776,123 +899,6 @@ else
|
|||||||
</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)</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" />
|
|
||||||
<small class="text-muted">-1 = 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="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 =>
|
private RenderFragment RenderStockTable() => __builder =>
|
||||||
{
|
{
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -928,8 +934,10 @@ else
|
|||||||
<td>
|
<td>
|
||||||
@if (!job.IsLocked)
|
@if (!job.IsLocked)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<div class="table-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -983,6 +991,20 @@ else
|
|||||||
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
||||||
<i class="bi bi-printer me-1"></i> Print Report
|
<i class="bi bi-printer me-1"></i> Print Report
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary ms-2" @onclick="() => showLengthsInInches = !showLengthsInInches"
|
||||||
|
aria-pressed="@showLengthsInInches">
|
||||||
|
@(showLengthsInInches ? "Show feet + inches" : "Show all inches")
|
||||||
|
</button>
|
||||||
|
@if (!job.IsLocked)
|
||||||
|
{
|
||||||
|
<button class="btn btn-warning ms-2" @onclick="LockJob" disabled="@lockingJob">
|
||||||
|
@if (lockingJob)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<i class="bi bi-lock me-1"></i>Lock Job
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (job.OptimizedAt.HasValue)
|
@if (job.OptimizedAt.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1023,7 +1045,7 @@ else
|
|||||||
<div class="col-md-3 col-6 mb-3">
|
<div class="col-md-3 col-6 mb-3">
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
<h2 class="card-title mb-0">@FormatResultLength(summary.TotalWaste)</h2>
|
||||||
<p class="card-text text-muted">Total Waste</p>
|
<p class="card-text text-muted">Total Waste</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1038,29 +1060,27 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase List -->
|
@if (job.CuttingTool != null)
|
||||||
<div class="card mb-4 print-purchase-list">
|
{
|
||||||
|
<div class="print-cut-method">
|
||||||
|
<strong>Cut Method:</strong> @job.CuttingTool.Name
|
||||||
|
<span class="ms-2">Kerf: @FormatResultLength((double)job.CuttingTool.KerfInches)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Material List: required stock lengths not covered by job stock -->
|
||||||
|
<div class="card mb-4 print-material-list">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
|
<h5 class="mb-0"><i class="bi bi-box-seam me-2"></i>Material List</h5>
|
||||||
@if (job.IsLocked)
|
@if (job.IsLocked)
|
||||||
{
|
{
|
||||||
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
<span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
|
|
||||||
@if (lockingJob)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
||||||
}
|
|
||||||
<i class="bi bi-lock me-1"></i>Lock Job
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (summary.TotalToBePurchasedBins == 0)
|
@if (summary.TotalToBePurchasedBins == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
|
<p class="text-muted mb-0">No additional material lengths are required beyond the stock configured for this job.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1080,7 +1100,7 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@materialResult.Material.DisplayName</td>
|
<td>@materialResult.Material.DisplayName</td>
|
||||||
<td>@ArchUnits.FormatFromInches(group.Key)</td>
|
<td>@FormatResultLength(group.Key)</td>
|
||||||
<td class="text-end">@group.Count()</td>
|
<td class="text-end">@group.Count()</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1108,7 @@ else
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="fw-bold">
|
<tr class="fw-bold">
|
||||||
<td colspan="2">Total</td>
|
<td colspan="2">Total Required</td>
|
||||||
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -1154,16 +1174,21 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@binNum</td>
|
<td>@binNum</td>
|
||||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
|
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.Length)</td>
|
||||||
<td>
|
<td>
|
||||||
@foreach (var item in entry.Bin.Items)
|
@foreach (var item in entry.Bin.Items)
|
||||||
{
|
{
|
||||||
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;">
|
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
|
||||||
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
@if (!string.IsNullOrWhiteSpace(item.Name))
|
||||||
|
{
|
||||||
|
<span class="cut-part-badge-name">@item.Name</span>
|
||||||
|
<span class="cut-part-badge-divider" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
<span class="cut-part-badge-length">@FormatResultLength(item.Length)</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
|
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
|
||||||
</tr>
|
</tr>
|
||||||
binNum++;
|
binNum++;
|
||||||
}
|
}
|
||||||
@@ -1227,21 +1252,33 @@ else
|
|||||||
await JS.InvokeVoidAsync("printWithTitle", filename);
|
await JS.InvokeVoidAsync("printWithTitle", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowAddCustomStock()
|
private async Task ShowAddStockModal()
|
||||||
{
|
{
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
|
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
|
||||||
stockSelectedShape = null;
|
stockSelectedShape = null;
|
||||||
showStockForm = false;
|
stockSelectedMaterialId = 0;
|
||||||
showCustomStockForm = true;
|
availableStockItems.Clear();
|
||||||
stockErrorMessage = null;
|
stockErrorMessage = null;
|
||||||
|
importErrorMessage = null;
|
||||||
|
importCandidates.Clear();
|
||||||
|
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
|
||||||
|
showStockModal = true;
|
||||||
|
|
||||||
|
if (job.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
await LoadImportCandidatesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelStockForm()
|
private void CloseStockModal()
|
||||||
{
|
{
|
||||||
showStockForm = false;
|
showStockModal = false;
|
||||||
showCustomStockForm = false;
|
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
|
newStock = new();
|
||||||
|
importCandidates.Clear();
|
||||||
|
stockErrorMessage = null;
|
||||||
|
importErrorMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnStockShapeChanged()
|
private async Task OnStockShapeChanged()
|
||||||
@@ -1256,17 +1293,10 @@ else
|
|||||||
{
|
{
|
||||||
newStock.MaterialId = stockSelectedMaterialId;
|
newStock.MaterialId = stockSelectedMaterialId;
|
||||||
newStock.StockItemId = null;
|
newStock.StockItemId = null;
|
||||||
if (stockSelectedMaterialId > 0)
|
await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
|
||||||
{
|
|
||||||
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
availableStockItems.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EditStock(JobStock stock)
|
private async Task EditStock(JobStock stock)
|
||||||
{
|
{
|
||||||
editingStock = stock;
|
editingStock = stock;
|
||||||
newStock = new JobStock
|
newStock = new JobStock
|
||||||
@@ -1284,18 +1314,23 @@ else
|
|||||||
stockSelectedShape = stock.Material?.Shape;
|
stockSelectedShape = stock.Material?.Shape;
|
||||||
stockSelectedMaterialId = stock.MaterialId;
|
stockSelectedMaterialId = stock.MaterialId;
|
||||||
stockErrorMessage = null;
|
stockErrorMessage = null;
|
||||||
|
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
|
||||||
|
showStockModal = true;
|
||||||
|
|
||||||
if (stock.IsCustomLength)
|
if (!stock.IsCustomLength)
|
||||||
{
|
{
|
||||||
showStockForm = false;
|
// Load the candidate stock lengths without going through OnStockMaterialChanged,
|
||||||
showCustomStockForm = true;
|
// which would reset newStock.StockItemId and blank the prefilled selection.
|
||||||
|
// Awaited (not fire-and-forget) so Blazor re-renders once the list arrives.
|
||||||
|
await LoadAvailableStockItemsAsync(stock.MaterialId);
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
private async Task LoadAvailableStockItemsAsync(int materialId)
|
||||||
{
|
{
|
||||||
showStockForm = true;
|
availableStockItems = materialId > 0
|
||||||
showCustomStockForm = false;
|
? await JobService.GetAvailableStockForMaterialAsync(materialId)
|
||||||
_ = OnStockMaterialChanged();
|
: new List<StockItem>();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveStockFromInventoryAsync()
|
private async Task SaveStockFromInventoryAsync()
|
||||||
@@ -1347,7 +1382,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showStockForm = false;
|
showStockModal = false;
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
packResult = null;
|
packResult = null;
|
||||||
summary = null;
|
summary = null;
|
||||||
@@ -1394,7 +1429,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showCustomStockForm = false;
|
showStockModal = false;
|
||||||
editingStock = null;
|
editingStock = null;
|
||||||
packResult = null;
|
packResult = null;
|
||||||
summary = null;
|
summary = null;
|
||||||
@@ -1408,13 +1443,12 @@ else
|
|||||||
summary = null;
|
summary = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import modal methods
|
// Loads inventory stock candidates for the modal's "From Inventory" tab
|
||||||
private async Task ShowImportModal()
|
private async Task LoadImportCandidatesAsync()
|
||||||
{
|
{
|
||||||
importErrorMessage = null;
|
importErrorMessage = null;
|
||||||
importCandidates.Clear();
|
importCandidates.Clear();
|
||||||
loadingImport = true;
|
loadingImport = true;
|
||||||
showImportModal = true;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1449,13 +1483,6 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CloseImportModal()
|
|
||||||
{
|
|
||||||
showImportModal = false;
|
|
||||||
importCandidates.Clear();
|
|
||||||
importErrorMessage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleAllImportCandidates(bool selected)
|
private void ToggleAllImportCandidates(bool selected)
|
||||||
{
|
{
|
||||||
foreach (var c in importCandidates)
|
foreach (var c in importCandidates)
|
||||||
@@ -1490,7 +1517,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
showImportModal = false;
|
showStockModal = false;
|
||||||
importCandidates.Clear();
|
importCandidates.Clear();
|
||||||
packResult = null;
|
packResult = null;
|
||||||
summary = null;
|
summary = null;
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ else
|
|||||||
<td>@(job.CuttingTool?.Name ?? "-")</td>
|
<td>@(job.CuttingTool?.Name ?? "-")</td>
|
||||||
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="table-actions">
|
||||||
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ else
|
|||||||
<td>@material.Grade</td>
|
<td>@material.Grade</td>
|
||||||
<td>@material.Size</td>
|
<td>@material.Size</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<div class="table-actions">
|
||||||
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ else
|
|||||||
<td>@item.Material.Size</td>
|
<td>@item.Material.Size</td>
|
||||||
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<div class="table-actions">
|
||||||
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ else
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="table-actions">
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"exportedAt": "2026-02-16T17:09:52.843008+00:00",
|
|
||||||
"suppliers": [
|
|
||||||
{
|
|
||||||
"name": "Alro Steel"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cuttingTools": [
|
|
||||||
{
|
|
||||||
"name": "Bandsaw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chop Saw",
|
|
||||||
"kerfInches": 0.125,
|
|
||||||
"isDefault": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cold Cut Saw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hacksaw",
|
|
||||||
"kerfInches": 0.0625,
|
|
||||||
"isDefault": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"materials": {
|
|
||||||
"angles": [],
|
|
||||||
"channels": [],
|
|
||||||
"flatBars": [],
|
|
||||||
"iBeams": [],
|
|
||||||
"pipes": [],
|
|
||||||
"rectangularTubes": [],
|
|
||||||
"roundBars": [],
|
|
||||||
"roundTubes": [],
|
|
||||||
"squareBars": [],
|
|
||||||
"squareTubes": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using CutList.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CutList.Web.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260802212500_RemoveJobNumberPadding")]
|
||||||
|
public partial class RemoveJobNumberPadding : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Jobs
|
||||||
|
SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))
|
||||||
|
WHERE JobNumber LIKE 'JOB-%';
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Jobs
|
||||||
|
SET JobNumber = 'JOB-' + RIGHT('00000' + CAST(Id AS varchar(5)), 5)
|
||||||
|
WHERE JobNumber LIKE 'JOB-%';
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
|
|||||||
builder.Services.AddScoped<MaterialService>();
|
builder.Services.AddScoped<MaterialService>();
|
||||||
builder.Services.AddScoped<StockItemService>();
|
builder.Services.AddScoped<StockItemService>();
|
||||||
builder.Services.AddScoped<JobService>();
|
builder.Services.AddScoped<JobService>();
|
||||||
|
builder.Services.AddScoped<OverviewService>();
|
||||||
builder.Services.AddScoped<CutListPackingService>();
|
builder.Services.AddScoped<CutListPackingService>();
|
||||||
builder.Services.AddScoped<ReportService>();
|
builder.Services.AddScoped<ReportService>();
|
||||||
builder.Services.AddScoped<CatalogService>();
|
builder.Services.AddScoped<CatalogService>();
|
||||||
|
|||||||
@@ -42,35 +42,17 @@ public class JobService
|
|||||||
{
|
{
|
||||||
await using var context = _factory.CreateDbContext();
|
await using var context = _factory.CreateDbContext();
|
||||||
job ??= new Job();
|
job ??= new Job();
|
||||||
job.JobNumber = await GenerateJobNumberAsync(context);
|
job.JobNumber = CreateTemporaryJobNumber();
|
||||||
job.CreatedAt = DateTime.UtcNow;
|
job.CreatedAt = DateTime.UtcNow;
|
||||||
context.Jobs.Add(job);
|
context.Jobs.Add(job);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
job.JobNumber = $"JOB-{job.Id}";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateJobNumberAsync()
|
private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
|
||||||
{
|
|
||||||
await using var context = _factory.CreateDbContext();
|
|
||||||
return await GenerateJobNumberAsync(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> GenerateJobNumberAsync(ApplicationDbContext context)
|
|
||||||
{
|
|
||||||
var maxNumber = await context.Jobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith("JOB-"))
|
|
||||||
.Select(j => j.JobNumber)
|
|
||||||
.MaxAsync() as string;
|
|
||||||
|
|
||||||
if (maxNumber == null)
|
|
||||||
return "JOB-00001";
|
|
||||||
|
|
||||||
var numPart = maxNumber.Substring(4);
|
|
||||||
if (int.TryParse(numPart, out var num))
|
|
||||||
return $"JOB-{num + 1:D5}";
|
|
||||||
|
|
||||||
return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Job> QuickCreateAsync(string? customer = null)
|
public async Task<Job> QuickCreateAsync(string? customer = null)
|
||||||
{
|
{
|
||||||
@@ -142,7 +124,7 @@ public class JobService
|
|||||||
|
|
||||||
var duplicate = new Job
|
var duplicate = new Job
|
||||||
{
|
{
|
||||||
JobNumber = await GenerateJobNumberAsync(context),
|
JobNumber = CreateTemporaryJobNumber(),
|
||||||
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
||||||
Customer = original.Customer,
|
Customer = original.Customer,
|
||||||
CuttingToolId = original.CuttingToolId,
|
CuttingToolId = original.CuttingToolId,
|
||||||
@@ -153,6 +135,9 @@ public class JobService
|
|||||||
context.Jobs.Add(duplicate);
|
context.Jobs.Add(duplicate);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
duplicate.JobNumber = $"JOB-{duplicate.Id}";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Copy parts
|
// Copy parts
|
||||||
foreach (var part in original.Parts)
|
foreach (var part in original.Parts)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using CutList.Web.Data;
|
||||||
|
using CutList.Web.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CutList.Web.Services;
|
||||||
|
|
||||||
|
public class OverviewService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ApplicationDbContext> _factory;
|
||||||
|
|
||||||
|
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OverviewSnapshot> GetSnapshotAsync()
|
||||||
|
{
|
||||||
|
await using var context = _factory.CreateDbContext();
|
||||||
|
|
||||||
|
var recentJobs = await context.Jobs
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
|
.Take(8)
|
||||||
|
.Select(j => new RecentJobOverview(
|
||||||
|
j.Id,
|
||||||
|
j.JobNumber,
|
||||||
|
j.Name,
|
||||||
|
j.Customer,
|
||||||
|
j.CreatedAt,
|
||||||
|
j.LockedAt != null,
|
||||||
|
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var frequentStock = await context.JobStocks
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
g.Key.MaterialId,
|
||||||
|
g.Key.LengthInches,
|
||||||
|
g.Key.Shape,
|
||||||
|
g.Key.Size,
|
||||||
|
JobCount = g.Select(s => s.JobId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.OrderByDescending(s => s.JobCount)
|
||||||
|
.ThenBy(s => s.Shape)
|
||||||
|
.ThenBy(s => s.Size)
|
||||||
|
.ThenBy(s => s.LengthInches)
|
||||||
|
.Take(5)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalJobs = await context.Jobs.CountAsync();
|
||||||
|
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
|
||||||
|
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
|
||||||
|
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
|
||||||
|
|
||||||
|
return new OverviewSnapshot(
|
||||||
|
totalJobs,
|
||||||
|
openJobs,
|
||||||
|
totalParts,
|
||||||
|
activeStockItems,
|
||||||
|
recentJobs,
|
||||||
|
frequentStock.Select(s => new FrequentStockOverview(
|
||||||
|
s.MaterialId,
|
||||||
|
$"{s.Shape.GetDisplayName()} - {s.Size}",
|
||||||
|
s.LengthInches,
|
||||||
|
s.JobCount)).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OverviewSnapshot(
|
||||||
|
int TotalJobs,
|
||||||
|
int OpenJobs,
|
||||||
|
int TotalParts,
|
||||||
|
int ActiveStockItems,
|
||||||
|
IReadOnlyList<RecentJobOverview> RecentJobs,
|
||||||
|
IReadOnlyList<FrequentStockOverview> FrequentStock);
|
||||||
|
|
||||||
|
public record RecentJobOverview(
|
||||||
|
int Id,
|
||||||
|
string JobNumber,
|
||||||
|
string? Name,
|
||||||
|
string? Customer,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
bool IsLocked,
|
||||||
|
int PartCount);
|
||||||
|
|
||||||
|
public record FrequentStockOverview(
|
||||||
|
int MaterialId,
|
||||||
|
string MaterialName,
|
||||||
|
decimal LengthInches,
|
||||||
|
int JobCount);
|
||||||
@@ -88,8 +88,8 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
background-color: #f3f1ee;
|
background-color: #edf2f5;
|
||||||
border-bottom: 1px solid #e0ddd8;
|
border-bottom: 1px solid #d4dee5;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,7 +145,7 @@ main {
|
|||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row, article {
|
article {
|
||||||
padding-left: 2rem !important;
|
padding-left: 2rem !important;
|
||||||
padding-right: 1.5rem !important;
|
padding-right: 1.5rem !important;
|
||||||
}
|
}
|
||||||
@@ -225,3 +225,95 @@ h1 {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
|
||||||
|
:root {
|
||||||
|
--ink: #17212b;
|
||||||
|
--muted: #617282;
|
||||||
|
--paper: #f3f6f8;
|
||||||
|
--paper-bright: #ffffff;
|
||||||
|
--line: #d4dee5;
|
||||||
|
--accent: #2b75a8;
|
||||||
|
--accent-dark: #1d5d8d;
|
||||||
|
--navy: #203747;
|
||||||
|
--rail: #18242e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { background: var(--paper); color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
body { letter-spacing: -.01em; }
|
||||||
|
main { background: var(--paper); box-shadow: none; min-width: 0; }
|
||||||
|
.sidebar { background: var(--rail); border-right: 1px solid #314653; }
|
||||||
|
.content { margin: 0 auto; max-width: 1440px; padding: 2.75rem clamp(1.5rem, 4vw, 4.5rem) 4rem; }
|
||||||
|
.eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
|
||||||
|
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 650; letter-spacing: -.025em; }
|
||||||
|
h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem); line-height: 1.04; }
|
||||||
|
h2 { font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.12; }
|
||||||
|
.job-title { font-size: clamp(1.5rem, 2.1vw, 2.1rem); line-height: 1.12; }
|
||||||
|
a, .btn-link { color: var(--accent-dark); }
|
||||||
|
.btn { border-radius: .35rem; font-size: .85rem; font-weight: 750; letter-spacing: .01em; padding: .65rem 1rem; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
|
||||||
|
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
|
||||||
|
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||||
|
.btn-success { background: #267c6c; border-color: #267c6c; }
|
||||||
|
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
|
||||||
|
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
|
||||||
|
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
|
||||||
|
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
|
||||||
|
.card-header { background: #edf2f5; }
|
||||||
|
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
|
||||||
|
.table > :not(caption) > * > * { padding: .85rem .75rem; }
|
||||||
|
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
|
||||||
|
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
|
||||||
|
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
|
||||||
|
.alert { border-radius: .4rem; border-width: 1px; }
|
||||||
|
|
||||||
|
/* Results cut badges keep the part number and its length visually distinct. */
|
||||||
|
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
|
||||||
|
.cut-part-badge-name { padding: .35em .55em; }
|
||||||
|
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
|
||||||
|
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
|
||||||
|
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
|
||||||
|
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
|
||||||
|
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
|
||||||
|
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
|
||||||
|
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
|
||||||
|
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
|
||||||
|
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
|
||||||
|
.overview-recent-jobs { min-width: 0; }
|
||||||
|
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
|
||||||
|
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
|
||||||
|
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
|
||||||
|
|
||||||
|
/* Dashboard */
|
||||||
|
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
|
||||||
|
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||||
|
.dashboard-hero .eyebrow { color: #a8c5d7; }
|
||||||
|
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
|
||||||
|
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
|
||||||
|
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
|
||||||
|
.btn-quiet:hover { color: #8fc5e4; }
|
||||||
|
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
|
||||||
|
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
|
||||||
|
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
|
||||||
|
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
|
||||||
|
.dashboard-section { margin-top: 4rem; }
|
||||||
|
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||||
|
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
|
||||||
|
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
|
||||||
|
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
|
||||||
|
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
|
||||||
|
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
|
||||||
|
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
|
||||||
|
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
|
||||||
|
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
|
||||||
|
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
|
||||||
|
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
|
||||||
|
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||||
|
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
|
||||||
|
|||||||
@@ -141,14 +141,27 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The selected tool belongs on the paper cut report, not in the interactive results layout. */
|
||||||
|
.print-cut-method {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Print styles - Compact layout to save paper */
|
/* Print styles - Compact layout to save paper */
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: economy;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: economy;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-screen-only,
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.top-row,
|
.top-row,
|
||||||
.page > main > .top-row,
|
.page > main > .top-row,
|
||||||
@@ -304,8 +317,16 @@
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep purchase list with cut lists to save paper */
|
.print-cut-method {
|
||||||
.print-purchase-list {
|
display: block !important;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: -0.25rem 0 0.5rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep material list with cut lists to save paper */
|
||||||
|
.print-material-list {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
@@ -329,6 +350,15 @@
|
|||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badges lose their blue fills in print, so force their white screen text and divider to black. */
|
||||||
|
.cut-part-badge {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cut-part-badge-divider {
|
||||||
|
border-left: 1px solid #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cut list tables: hide screen header, show repeating print header in thead */
|
/* Cut list tables: hide screen header, show repeating print header in thead */
|
||||||
.cutlist-material-screen-header {
|
.cutlist-material-screen-header {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -365,6 +395,12 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep each stock-bar result, its cuts, and its waste on one printed page. */
|
||||||
|
.cutlist-material-card tbody tr {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduce spacing */
|
/* Reduce spacing */
|
||||||
.mb-4 {
|
.mb-4 {
|
||||||
margin-bottom: 0.5rem !important;
|
margin-bottom: 0.5rem !important;
|
||||||
|
|||||||
+14
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -69,6 +71,18 @@ Global
|
|||||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
|
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,819 @@
|
|||||||
|
# Unified Add/Edit Stock Modal Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the Job Edit page's Stock tab three inconsistent add/edit UI surfaces (bulk import modal, inline custom-length form, inline edit forms) with a single "Add Stock" button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
|
||||||
|
|
||||||
|
**Architecture:** Single-file Razor component change in `CutList.Web/Components/Pages/Jobs/Edit.razor`. Three boolean visibility flags (`showStockForm`, `showCustomStockForm`, `showImportModal`) collapse into one (`showStockModal`) plus a `StockModalTab` enum (`Inventory`/`Custom`) for tab selection; the existing `editingStock` field (already present) continues to discriminate Add vs Edit mode. All markup for the three old surfaces is merged into one top-level modal block, following the same top-level-markup-block pattern already used by the existing "Part Modal Dialog" and (soon-to-be-replaced) "Import Stock Modal" blocks in this file.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8 Blazor Server, Bootstrap 5 modal/tab markup. No automated test project exists for `CutList.Web` (confirmed: no `*.Tests.csproj` in the solution). Verification is `dotnet build` for compile correctness plus manual browser verification via the `webapp-testing` skill (Playwright) for behavior — there is no unit-test step in this plan.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Full design spec: `docs/superpowers/specs/2026-08-01-unified-add-stock-modal-design.md` — every requirement in it must be reflected below.
|
||||||
|
- No changes to the Parts tab, its modal, `JobService`, or any API surface (spec's "Out of Scope" section).
|
||||||
|
- No change to validation rules or business logic — UI consolidation only. All four existing handler methods (`SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, and the renamed candidate-loading method) keep their existing validation logic verbatim.
|
||||||
|
- Match existing code style in this file: top-level markup blocks (not extra `RenderFragment` C# methods) for modals, mirroring the existing "Part Modal Dialog" block's inline `@if`/`else` branching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Consolidate Stock tab add/edit UI into one modal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CutList.Web/Components/Pages/Jobs/Edit.razor`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `private enum StockModalTab { Inventory, Custom }`, `private bool showStockModal`, `private StockModalTab stockModalTab`, `private async Task ShowAddStockModal()`, `private void CloseStockModal()`, `private async Task LoadImportCandidatesAsync()` — these are the only new/renamed symbols; every other field/method referenced below (`newStock`, `editingStock`, `stockErrorMessage`, `stockSelectedShape`, `stockSelectedMaterialId`, `availableStockItems`, `loadingImport`, `importCandidates`, `importErrorMessage`, `ImportStockCandidate`, `job`, `DistinctShapes`, `materials`, `ArchUnits`, `OnStockShapeChanged`, `OnStockMaterialChanged`, `SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, `ToggleAllImportCandidates`, `EditStock`, `DeleteStock`, `RenderStockTable`) already exists in the file and keeps its current signature.
|
||||||
|
|
||||||
|
This is one cohesive task — the markup and the code-behind are too interdependent in a single `.razor` file to compile in a valid intermediate state, so it's implemented as one connected set of edits followed by a single build/verification gate.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the Stock-form and Import-modal state fields with the unified modal state**
|
||||||
|
|
||||||
|
In the `@code` block, find this region (currently around line 353-367):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Import modal
|
||||||
|
private bool showImportModal;
|
||||||
|
private bool loadingImport;
|
||||||
|
private List<ImportStockCandidate> importCandidates = new();
|
||||||
|
private string? importErrorMessage;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
|
||||||
|
private enum StockModalTab { Inventory, Custom }
|
||||||
|
private bool showStockModal;
|
||||||
|
private StockModalTab stockModalTab = StockModalTab.Inventory;
|
||||||
|
private JobStock newStock = new();
|
||||||
|
private JobStock? editingStock;
|
||||||
|
private string? stockErrorMessage;
|
||||||
|
private MaterialShape? stockSelectedShape;
|
||||||
|
private int stockSelectedMaterialId;
|
||||||
|
private List<StockItem> availableStockItems = new();
|
||||||
|
private bool loadingImport;
|
||||||
|
private List<ImportStockCandidate> importCandidates = new();
|
||||||
|
private string? importErrorMessage;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `RenderStockTab` to use a single "Add Stock" button and drop the inline form rendering**
|
||||||
|
|
||||||
|
Find (currently around line 737-777):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 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>
|
||||||
|
@if (!job.IsLocked)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
|
||||||
|
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
||||||
|
Import from Inventory
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-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>
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 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>
|
||||||
|
@if (!job.IsLocked)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@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>
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete `RenderStockFromInventoryForm` and `RenderCustomStockForm`**
|
||||||
|
|
||||||
|
Delete these two methods entirely (currently around lines 779-894, immediately after the new `RenderStockTab` and before `RenderStockTable`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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>
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(Their field content is reused, without the wrapping `<div class="border...">`/`<h6>`/footer-button markup, inside the new modal in Step 6.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace `ShowAddCustomStock`/`CancelStockForm` with `ShowAddStockModal`/`CloseStockModal`, and rename `ShowImportModal`/`CloseImportModal`**
|
||||||
|
|
||||||
|
Find (currently around line 1230-1246):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task ShowAddStockModal()
|
||||||
|
{
|
||||||
|
editingStock = null;
|
||||||
|
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
|
||||||
|
stockSelectedShape = null;
|
||||||
|
stockSelectedMaterialId = 0;
|
||||||
|
availableStockItems.Clear();
|
||||||
|
stockErrorMessage = null;
|
||||||
|
importErrorMessage = null;
|
||||||
|
importCandidates.Clear();
|
||||||
|
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
|
||||||
|
showStockModal = true;
|
||||||
|
|
||||||
|
if (job.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
await LoadImportCandidatesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseStockModal()
|
||||||
|
{
|
||||||
|
showStockModal = false;
|
||||||
|
editingStock = null;
|
||||||
|
importCandidates.Clear();
|
||||||
|
stockErrorMessage = null;
|
||||||
|
importErrorMessage = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `EditStock` to open the unified modal**
|
||||||
|
|
||||||
|
Find (currently around line 1269-1299):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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;
|
||||||
|
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
|
||||||
|
showStockModal = true;
|
||||||
|
|
||||||
|
if (!stock.IsCustomLength)
|
||||||
|
{
|
||||||
|
_ = OnStockMaterialChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the three save/import completion handlers to close the unified modal**
|
||||||
|
|
||||||
|
In `SaveStockFromInventoryAsync` (currently around line 1349-1353), find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showStockForm = false;
|
||||||
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(this is the end of `SaveStockFromInventoryAsync`) and replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showStockModal = false;
|
||||||
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `SaveCustomStockAsync` (currently around line 1396-1401), find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showCustomStockForm = false;
|
||||||
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showStockModal = false;
|
||||||
|
editingStock = null;
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `ImportSelectedStockAsync` (currently around line 1492-1497), find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showImportModal = false;
|
||||||
|
importCandidates.Clear();
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||||
|
showStockModal = false;
|
||||||
|
importCandidates.Clear();
|
||||||
|
packResult = null;
|
||||||
|
summary = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Rename `ShowImportModal` to `LoadImportCandidatesAsync` and delete `CloseImportModal`**
|
||||||
|
|
||||||
|
Find (currently around line 1411-1457):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Import modal methods
|
||||||
|
private async Task ShowImportModal()
|
||||||
|
{
|
||||||
|
importErrorMessage = null;
|
||||||
|
importCandidates.Clear();
|
||||||
|
loadingImport = true;
|
||||||
|
showImportModal = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
|
||||||
|
var existingStockItemIds = job.Stock
|
||||||
|
.Where(s => s.StockItemId.HasValue)
|
||||||
|
.Select(s => s.StockItemId!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var materialId in materialIds)
|
||||||
|
{
|
||||||
|
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
|
||||||
|
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
|
||||||
|
{
|
||||||
|
importCandidates.Add(new ImportStockCandidate
|
||||||
|
{
|
||||||
|
StockItem = item,
|
||||||
|
Selected = true,
|
||||||
|
Quantity = -1,
|
||||||
|
Priority = 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
importErrorMessage = $"Error loading stock: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingImport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseImportModal()
|
||||||
|
{
|
||||||
|
showImportModal = false;
|
||||||
|
importCandidates.Clear();
|
||||||
|
importErrorMessage = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Loads inventory stock candidates for the modal's "From Inventory" tab
|
||||||
|
private async Task LoadImportCandidatesAsync()
|
||||||
|
{
|
||||||
|
importErrorMessage = null;
|
||||||
|
importCandidates.Clear();
|
||||||
|
loadingImport = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
|
||||||
|
var existingStockItemIds = job.Stock
|
||||||
|
.Where(s => s.StockItemId.HasValue)
|
||||||
|
.Select(s => s.StockItemId!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var materialId in materialIds)
|
||||||
|
{
|
||||||
|
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
|
||||||
|
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
|
||||||
|
{
|
||||||
|
importCandidates.Add(new ImportStockCandidate
|
||||||
|
{
|
||||||
|
StockItem = item,
|
||||||
|
Selected = true,
|
||||||
|
Quantity = -1,
|
||||||
|
Priority = 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
importErrorMessage = $"Error loading stock: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingImport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Replace the "Import Stock Modal" markup block with the unified "Add/Edit Stock Modal" block**
|
||||||
|
|
||||||
|
Find the entire top-level block currently at lines 232-324:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
@* Import Stock Modal *@
|
||||||
|
@if (showImportModal)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(the full existing block — everything between `@* Import Stock Modal *@` and its closing `}`)
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```razor
|
||||||
|
@* Add/Edit Stock Modal *@
|
||||||
|
@if (showStockModal)
|
||||||
|
{
|
||||||
|
<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">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (editingStock == null)
|
||||||
|
{
|
||||||
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
|
||||||
|
type="button" disabled="@(job.Parts.Count == 0)"
|
||||||
|
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
|
||||||
|
@onclick="() => stockModalTab = StockModalTab.Inventory">
|
||||||
|
From Inventory
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
|
||||||
|
type="button"
|
||||||
|
@onclick="() => stockModalTab = StockModalTab.Custom">
|
||||||
|
Custom Length
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||||
|
{
|
||||||
|
@if (loadingImport)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<span class="spinner-border"></span>
|
||||||
|
<p class="mt-2 text-muted">Finding matching stock...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (importCandidates.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<p class="mb-2">No matching inventory stock found.</p>
|
||||||
|
<p class="small">Either no stock items exist for the materials in your parts, or they have already been added to this job.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(true)">Select All</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(false)">Select None</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">@importCandidates.Count(c => c.Selected) of @importCandidates.Count selected</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (var group in importCandidates
|
||||||
|
.GroupBy(c => c.StockItem.MaterialId)
|
||||||
|
.OrderBy(g => g.First().StockItem.Material.Shape)
|
||||||
|
.ThenBy(g => g.First().StockItem.Material.Size))
|
||||||
|
{
|
||||||
|
var material = group.First().StockItem.Material;
|
||||||
|
<h6 class="mt-3 mb-2 text-primary">@material.DisplayName</h6>
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px;"></th>
|
||||||
|
<th>Length</th>
|
||||||
|
<th style="width: 120px;">Qty to Use</th>
|
||||||
|
<th style="width: 100px;">Priority</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var candidate in group.OrderByDescending(c => c.StockItem.LengthInches))
|
||||||
|
{
|
||||||
|
<tr class="@(candidate.Selected ? "" : "text-muted")">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
|
||||||
|
</td>
|
||||||
|
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
|
||||||
|
min="-1" disabled="@(!candidate.Selected)" />
|
||||||
|
<small class="text-muted">-1 = unlimited</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="candidate.Priority"
|
||||||
|
min="1" disabled="@(!candidate.Selected)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(importErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||||
|
{
|
||||||
|
<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)</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" />
|
||||||
|
<small class="text-muted">-1 = 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
|
||||||
|
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||||
|
{
|
||||||
|
@if (importCandidates.Count > 0)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
||||||
|
disabled="@(!importCandidates.Any(c => c.Selected))">
|
||||||
|
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
||||||
|
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note this new block goes in the exact same position (between the "Part Modal Dialog" block and the `@code` block) that the old "Import Stock Modal" block occupied.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Verify no leftover references to removed identifiers**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -nE "showStockForm|showCustomStockForm|showImportModal|ShowAddCustomStock|CancelStockForm|CloseImportModal\b|RenderStockFromInventoryForm|RenderCustomStockForm" CutList.Web/Components/Pages/Jobs/Edit.razor
|
||||||
|
```
|
||||||
|
Expected: no output (all removed/renamed identifiers are gone). If anything matches, fix it before proceeding — it's a leftover reference that Step 10's build may or may not catch (e.g. a stray comment).
|
||||||
|
|
||||||
|
- [ ] **Step 10: Build and verify no compile errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build CutList.Web/CutList.Web.csproj
|
||||||
|
```
|
||||||
|
Expected: `Build succeeded.` with 0 errors. Fix any errors (most likely causes: a missed rename, or a Razor `@`-prefix mismatch in the nested `@if` blocks from Step 8 — compare carefully against the nesting depth shown above, every nested C# control-flow keyword inside markup context needs its own `@` prefix).
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CutList.Web/Components/Pages/Jobs/Edit.razor
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat: unify add/edit stock UI into a single modal on Job Edit page
|
||||||
|
|
||||||
|
Replaces the three separate surfaces (bulk import modal, inline custom-length
|
||||||
|
form, inline edit forms) with one "Add Stock" button and modal that handles
|
||||||
|
both add and edit, for both inventory-sourced and custom-length stock.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Manual verification of every Stock tab flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None modified — this task is verification only, using the `webapp-testing` skill (Playwright) against the running app. Fix-forward edits to `CutList.Web/Components/Pages/Jobs/Edit.razor` are in scope if a scenario below reveals a bug.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the running `CutList.Web` app (`dotnet run --project CutList.Web/CutList.Web.csproj`, default `http://localhost:5270`) and an existing or newly-created Job on the `/jobs/{id}` Edit page's Stock tab.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the app**
|
||||||
|
|
||||||
|
Run (background):
|
||||||
|
```bash
|
||||||
|
dotnet run --project CutList.Web/CutList.Web.csproj
|
||||||
|
```
|
||||||
|
Expected: app listening on `http://localhost:5270`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify "Add Stock" opens the modal defaulting to the Inventory tab when parts exist**
|
||||||
|
|
||||||
|
Using Playwright (webapp-testing skill): navigate to a job that already has at least one part and no stock yet (create one via the Parts tab if none exists), switch to the Stock tab.
|
||||||
|
|
||||||
|
Expected: one "Add Stock" button (no separate "Import from Inventory" / "Add Custom Length" buttons). Clicking it opens a modal titled "Add Stock" with two tabs, "From Inventory" active by default, showing the bulk-select candidate table (or a "No matching inventory stock found" message if the material has no inventory stock items — either is acceptable, both are pre-existing behaviors).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify bulk import from the Inventory tab**
|
||||||
|
|
||||||
|
If candidates are present in the table from Step 2 (add a Stock Item via the `/stock/new` page first for the job's material if the list is empty, then reopen the modal), select at least one row's checkbox and click "Import N Item(s)".
|
||||||
|
|
||||||
|
Expected: modal closes, the new stock row appears in the Stock table with the correct material, length, quantity, and priority.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify Custom Length tab add flow**
|
||||||
|
|
||||||
|
Reopen "Add Stock", click the "Custom Length" tab, fill in Shape, Size, Length, Quantity, Priority, click "Add Stock".
|
||||||
|
|
||||||
|
Expected: modal closes, the new custom-length row appears in the Stock table tagged with the "Custom" badge.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the Inventory tab is disabled when the job has no parts**
|
||||||
|
|
||||||
|
Create a new job with no parts yet, go to its Stock tab, click "Add Stock".
|
||||||
|
|
||||||
|
Expected: modal opens defaulting to the "Custom Length" tab; the "From Inventory" tab is visibly disabled and shows the "Add parts first to match against inventory" tooltip on hover; clicking it does nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify editing an inventory-sourced stock row**
|
||||||
|
|
||||||
|
On a job with an inventory-sourced stock row (from Step 3), click its Edit (pencil) button.
|
||||||
|
|
||||||
|
Expected: modal opens titled "Edit Stock" with no tab nav, showing the single inventory form (Shape/Size/Stock Length dropdown/Qty/Priority) prefilled with the row's current values. Change the quantity and click "Save Changes".
|
||||||
|
|
||||||
|
Expected: modal closes, the table row reflects the updated quantity.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify editing a custom-length stock row**
|
||||||
|
|
||||||
|
On the custom-length row (from Step 4), click its Edit button.
|
||||||
|
|
||||||
|
Expected: modal opens titled "Edit Stock" with no tab nav, showing the custom form (Shape/Size/Length/Qty/Priority) prefilled. Change the priority and click "Save Changes".
|
||||||
|
|
||||||
|
Expected: modal closes, the table row reflects the updated priority.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify Cancel discards changes in both Add and Edit mode**
|
||||||
|
|
||||||
|
Open "Add Stock", change a few fields on the Custom Length tab, click "Cancel". Reopen and confirm the fields are reset (not retaining the discarded values). Then click Edit on any row, change a field, click "Cancel", and confirm the table row is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Verify locked jobs still hide the button**
|
||||||
|
|
||||||
|
Lock a job (via the Results tab's "Lock Job" action, after running Optimize), return to the Stock tab.
|
||||||
|
|
||||||
|
Expected: "Add Stock" button is not rendered (matches pre-existing `@if (!job.IsLocked)` gating, unchanged by this plan) and Edit/Delete icons on stock rows are hidden.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Take a screenshot of the modal in both tab states for the record, then stop the app**
|
||||||
|
|
||||||
|
Capture a screenshot of the modal on the "From Inventory" tab and one on "Custom Length" tab (Playwright `browser_take_screenshot`). Stop the `dotnet run` background process.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Unified Add/Edit Stock Modal — Job Edit Page, Stock Tab
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Stock tab on the Job Edit page (`CutList.Web/Components/Pages/Jobs/Edit.razor`) currently exposes three separate, inconsistent UI surfaces for adding/editing job stock:
|
||||||
|
|
||||||
|
1. **Import from Inventory** button → opens a full modal (`showImportModal`) with a bulk multi-select table (grouped by material, checkboxes, Select All/None, per-row qty/priority). Disabled entirely when the job has no parts.
|
||||||
|
2. **Add Custom Length** button → toggles an inline (non-modal) single-row form (`showCustomStockForm`) in the card body.
|
||||||
|
3. **Edit** (pencil icon on an existing stock row) → reuses one of two inline single-row forms depending on the item type: the bulk-import modal's sibling single form (`showStockForm`, inventory-sourced) or the custom form (`showCustomStockForm`), neither as a modal for the inventory case's add path (only reachable via Edit today).
|
||||||
|
|
||||||
|
This is confusing: two different buttons for adding, plus edit behavior that doesn't match either add flow.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Consolidate all of this into a single **"Add Stock"** button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
|
||||||
|
|
||||||
|
## State Model
|
||||||
|
|
||||||
|
Replace the three separate visibility flags with:
|
||||||
|
|
||||||
|
- `showStockModal` (bool) — single modal visibility flag, replacing `showStockForm`, `showCustomStockForm`, and `showImportModal`.
|
||||||
|
- `stockModalTab` (enum: `Inventory`, `Custom`) — which tab is active. Only switchable in Add mode.
|
||||||
|
- `editingStock` (existing field) — `null` means Add mode; non-null means Edit mode, and fixes which tab is shown.
|
||||||
|
|
||||||
|
`newStock`, `importCandidates`, `stockErrorMessage`, `importErrorMessage`, `availableStockItems`, `stockSelectedShape`, `stockSelectedMaterialId` are all reused as-is from the current implementation.
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
The "Import from Inventory" and "Add Custom Length" buttons are replaced with a single **"Add Stock"** button. It is always enabled (not gated on `job.Parts.Count`). Clicking it:
|
||||||
|
|
||||||
|
- Sets `editingStock = null`.
|
||||||
|
- Opens the modal (`showStockModal = true`).
|
||||||
|
- Defaults `stockModalTab` to `Inventory` if `job.Parts.Count > 0`, otherwise `Custom`.
|
||||||
|
|
||||||
|
## Modal — Add Mode (`editingStock == null`)
|
||||||
|
|
||||||
|
Nav tabs shown: **From Inventory** | **Custom Length**.
|
||||||
|
|
||||||
|
- The **From Inventory** tab is disabled (with the existing tooltip copy, "Add parts first to match against inventory") when `job.Parts.Count == 0`. Its content is today's bulk-select table verbatim, reusing `importCandidates` and the existing `ShowImportModal` loading logic (grouped by material, Select All/None, per-row qty/priority inputs).
|
||||||
|
- The **Custom Length** tab content is today's single-row custom form verbatim (shape → size → length → qty → priority).
|
||||||
|
- Footer's primary button adapts to the active tab:
|
||||||
|
- Inventory tab: "Import N Item(s)", disabled when no candidates are selected — wired to `ImportSelectedStockAsync`.
|
||||||
|
- Custom tab: "Add Stock" — wired to `SaveCustomStockAsync`.
|
||||||
|
- "Cancel" button always present, closes the modal and resets `editingStock`, `newStock`, `importCandidates`, and error messages.
|
||||||
|
|
||||||
|
## Modal — Edit Mode (`editingStock != null`)
|
||||||
|
|
||||||
|
No tab nav is shown — a single fixed form matching the item's existing type:
|
||||||
|
|
||||||
|
- If `editingStock.IsCustomLength`: renders the same custom-form fields (shape → size → length → qty → priority), prefilled from the stock row — wired to `SaveCustomStockAsync`.
|
||||||
|
- Else: renders the same single-item inventory form fields (shape → size → stock-length dropdown → qty → priority), prefilled — wired to `SaveStockFromInventoryAsync`.
|
||||||
|
- Footer primary button: "Save Changes".
|
||||||
|
- "Cancel" button behaves as above.
|
||||||
|
|
||||||
|
`EditStock(stock)` sets `editingStock`, prefills `newStock` (as it does today), sets `stockModalTab` to match the item's type, and opens `showStockModal = true`.
|
||||||
|
|
||||||
|
## Method Reuse
|
||||||
|
|
||||||
|
The four existing handler methods keep their current logic unchanged — only the surrounding modal chrome (open/close state, which form renders) changes:
|
||||||
|
|
||||||
|
- `SaveStockFromInventoryAsync` — single inventory-sourced add/edit.
|
||||||
|
- `SaveCustomStockAsync` — single custom-length add/edit.
|
||||||
|
- `ImportSelectedStockAsync` — bulk inventory import (add only).
|
||||||
|
- `ShowImportModal` (the loading logic, not the modal-open flag itself) — populates `importCandidates` when the Inventory tab is active/opened in Add mode.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Unchanged. Each tab/mode keeps its own existing inline `alert-danger` validation message (`stockErrorMessage` for the two single-item forms, `importErrorMessage` for the bulk table).
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- No changes to the Parts tab or its modal.
|
||||||
|
- No changes to the underlying `JobService` methods or API surface.
|
||||||
|
- No change to validation rules or business logic — this is a UI consolidation only.
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Alro Steel SmartGrid Scraper — Remaining Steps
|
|
||||||
|
|
||||||
## Status: Script is READY TO RUN
|
|
||||||
|
|
||||||
The scraper at `scripts/AlroCatalog/scrape_alro.py` is complete and tested. Discovery mode confirmed it works correctly against the live site.
|
|
||||||
|
|
||||||
## What's Done
|
|
||||||
1. Script written with correct ASP.NET control IDs (discovered via `--discover` mode)
|
|
||||||
2. Level 1 (main grid) navigation: working
|
|
||||||
3. Level 2 (popup grid) navigation: working
|
|
||||||
4. Level 3 (dims panel) scraping: working — uses cascading dropdowns `ddlDimA` → `ddlDimB` → `ddlDimC` → `ddlLength`
|
|
||||||
5. Grade filter: 11 common grades (A-36, 1018, 1045, 1144, 12L14, etc.)
|
|
||||||
6. Size string normalization: "1-1/2\"" matches O'Neal format
|
|
||||||
7. Progress save/resume: working
|
|
||||||
8. Discovery mode verified: A-36 Round bars → 27 sizes, 80 items (lengths include "20 FT", "Custom Cut List", "Drop/Remnant" — non-stock entries filtered out in catalog builder)
|
|
||||||
|
|
||||||
## Remaining Steps
|
|
||||||
|
|
||||||
### Step 1: Run the full scrape
|
|
||||||
```bash
|
|
||||||
cd C:\Users\aisaacs\Desktop\Projects\CutList
|
|
||||||
python scripts/AlroCatalog/scrape_alro.py
|
|
||||||
```
|
|
||||||
- This scrapes all 3 categories (Bars, Pipe/Tube, Structural) for 11 filtered grades
|
|
||||||
- Takes ~30-60 minutes (cascading dropdown selections with 1.5s delay each)
|
|
||||||
- Progress saved incrementally to `scripts/AlroCatalog/alro-scrape-progress.json`
|
|
||||||
- If interrupted, resume with `python scripts/AlroCatalog/scrape_alro.py --resume`
|
|
||||||
- To scrape ALL grades: `python scripts/AlroCatalog/scrape_alro.py --all-grades`
|
|
||||||
|
|
||||||
### Step 2: Review output
|
|
||||||
- Output: `CutList.Web/Data/SeedData/alro-catalog.json`
|
|
||||||
- Verify material counts, shapes, sizes
|
|
||||||
- Spot-check dimensions against myalro.com
|
|
||||||
- Compare shape coverage to O'Neal catalog
|
|
||||||
|
|
||||||
### Step 3: Post-scrape adjustments (if needed)
|
|
||||||
|
|
||||||
**Dimension mapping for Structural/Pipe shapes**: The `build_size_and_dims()` function handles all shapes but Structural (Angle, Channel, Beam) and Pipe/Tube shapes haven't been tested live yet. After scraping, check the screenshots in `scripts/AlroCatalog/screenshots/` to verify dimension mapping. The first item of each new shape gets a screenshot + HTML dump.
|
|
||||||
|
|
||||||
**Known dimension mapping assumptions:**
|
|
||||||
- Angle: DimA = leg size, DimB = thickness → `"leg1 x leg2 x thickness"` (assumes equal legs)
|
|
||||||
- Channel: DimA = height, DimB = flange → needs verification
|
|
||||||
- IBeam: DimA = depth, DimB = weight/ft → `"W{depth} x {wt}"`
|
|
||||||
- SquareTube: DimA = size, DimB = wall
|
|
||||||
- RectTube: DimA = width, DimB = height, DimC = wall
|
|
||||||
- RoundTube: DimA = OD, DimB = wall
|
|
||||||
- Pipe: DimA = NPS, DimB = schedule
|
|
||||||
|
|
||||||
**If dimension mapping is wrong for a shape**: Edit the `build_size_and_dims()` function in `scrape_alro.py` and re-run just the catalog builder:
|
|
||||||
```python
|
|
||||||
python -c "
|
|
||||||
import json
|
|
||||||
from scripts.AlroCatalog.scrape_alro import build_catalog
|
|
||||||
data = json.load(open('scripts/AlroCatalog/alro-scrape-progress.json'))
|
|
||||||
catalog = build_catalog(data['items'])
|
|
||||||
json.dump(catalog, open('CutList.Web/Data/SeedData/alro-catalog.json', 'w'), indent=2)
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Part numbers (optional future enhancement)
|
|
||||||
The current scraper captures sizes and lengths but NOT part numbers. To get part numbers, the script would need to:
|
|
||||||
1. Select DimA + DimB + Length
|
|
||||||
2. Click the "Next >" button (`btnSearch`)
|
|
||||||
3. Capture part number from the results panel
|
|
||||||
4. Click Back
|
|
||||||
|
|
||||||
This adds significant time per item. The catalog works without part numbers — the supplierOfferings have empty partNumber/supplierDescription fields.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `scripts/AlroCatalog/scrape_alro.py` | The scraper script |
|
|
||||||
| `scripts/AlroCatalog/alro-scrape-progress.json` | Incremental progress (resume support) |
|
|
||||||
| `scripts/AlroCatalog/screenshots/` | Discovery HTML/screenshots per shape |
|
|
||||||
| `CutList.Web/Data/SeedData/alro-catalog.json` | Final output (same schema as oneals-catalog.json) |
|
|
||||||
| `CutList.Web/Data/SeedData/oneals-catalog.json` | Reference format |
|
|
||||||
|
|
||||||
## Grade Filter (editable in script)
|
|
||||||
Located at line ~50 in `scrape_alro.py`. Current filter:
|
|
||||||
- A-36, 1018 CF, 1018 HR, 1044 HR, 1045 CF, 1045 HR, 1045 TG&P
|
|
||||||
- 1144 CF, 1144 HR, 12L14 CF, A311/Stressproof
|
|
||||||
|
|
||||||
To add/remove grades, edit the `GRADE_FILTER` set in the script.
|
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
{
|
|
||||||
"completed": [
|
|
||||||
[
|
|
||||||
"Bars",
|
|
||||||
"A-36",
|
|
||||||
"ROUND"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Bars",
|
|
||||||
"A-36",
|
|
||||||
"FLAT"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".188",
|
|
||||||
"dim_a_text": "3/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".188",
|
|
||||||
"dim_a_text": "3/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".250",
|
|
||||||
"dim_a_text": "1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".313",
|
|
||||||
"dim_a_text": "5/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".375",
|
|
||||||
"dim_a_text": "3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".438",
|
|
||||||
"dim_a_text": "7/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".500",
|
|
||||||
"dim_a_text": "1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".563",
|
|
||||||
"dim_a_text": "9/16",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".625",
|
|
||||||
"dim_a_text": "5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".750",
|
|
||||||
"dim_a_text": "3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": ".875",
|
|
||||||
"dim_a_text": "7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.000",
|
|
||||||
"dim_a_text": "1",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.125",
|
|
||||||
"dim_a_text": "1 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.250",
|
|
||||||
"dim_a_text": "1 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.375",
|
|
||||||
"dim_a_text": "1 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.500",
|
|
||||||
"dim_a_text": "1 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.625",
|
|
||||||
"dim_a_text": "1 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.750",
|
|
||||||
"dim_a_text": "1 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "1.875",
|
|
||||||
"dim_a_text": "1 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.000",
|
|
||||||
"dim_a_text": "2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.125",
|
|
||||||
"dim_a_text": "2 1/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.250",
|
|
||||||
"dim_a_text": "2 1/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.375",
|
|
||||||
"dim_a_text": "2 3/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.500",
|
|
||||||
"dim_a_text": "2 1/2",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.625",
|
|
||||||
"dim_a_text": "2 5/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.750",
|
|
||||||
"dim_a_text": "2 3/4",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "2.875",
|
|
||||||
"dim_a_text": "2 7/8",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Custom Cut List",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "Drop/Remnant",
|
|
||||||
"length_inches": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"grade": "A-36",
|
|
||||||
"shape": "RoundBar",
|
|
||||||
"dim_a_val": "3.000",
|
|
||||||
"dim_a_text": "3",
|
|
||||||
"dim_b_val": null,
|
|
||||||
"dim_b_text": null,
|
|
||||||
"dim_c_val": null,
|
|
||||||
"dim_c_text": null,
|
|
||||||
"length_text": "20 FT",
|
|
||||||
"length_inches": 240.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,790 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Alro Steel SmartGrid Scraper
|
|
||||||
Scrapes myalro.com's SmartGrid for Carbon Steel materials and outputs
|
|
||||||
a catalog JSON matching the O'Neal catalog format.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scrape_alro.py # Scrape filtered grades (resumes from saved progress)
|
|
||||||
python scrape_alro.py --all-grades # Scrape ALL grades (slow)
|
|
||||||
python scrape_alro.py --discover # Scrape first item only, dump HTML/screenshots
|
|
||||||
python scrape_alro.py --fresh # Start fresh, ignoring saved progress
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from playwright.async_api import async_playwright, Page, TimeoutError as PwTimeout
|
|
||||||
from playwright_stealth import Stealth
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────────────
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Paths ────────────────────────────────────────────────────────────
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
||||||
OUTPUT_PATH = (SCRIPT_DIR / "../../CutList.Web/Data/SeedData/alro-catalog.json").resolve()
|
|
||||||
PROGRESS_PATH = SCRIPT_DIR / "alro-scrape-progress.json"
|
|
||||||
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
|
|
||||||
|
|
||||||
# ── Config ───────────────────────────────────────────────────────────
|
|
||||||
BASE_URL = "https://www.myalro.com/SmartGrid.aspx?PT=Steel&Clear=true"
|
|
||||||
DELAY = 5 # seconds between postback clicks
|
|
||||||
TIMEOUT = 15_000 # ms for element waits
|
|
||||||
CS_ROW = 4 # Carbon Steel row index in main grid
|
|
||||||
|
|
||||||
CATEGORIES = ["Bars", "Pipe / Tube", "Structural"]
|
|
||||||
|
|
||||||
# ┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
# │ GRADE FILTER — only these grades will be scraped. │
|
|
||||||
# │ Use --all-grades flag to override and scrape everything. │
|
|
||||||
# │ Grade names must match the gpname attribute exactly. │
|
|
||||||
# └─────────────────────────────────────────────────────────────────┘
|
|
||||||
GRADE_FILTER = {
|
|
||||||
# Common structural / general purpose
|
|
||||||
"A-36",
|
|
||||||
# Mild steel
|
|
||||||
"1018 CF",
|
|
||||||
"1018 HR",
|
|
||||||
# Medium carbon (shafts, gears, pins)
|
|
||||||
"1045 CF",
|
|
||||||
"1045 HR",
|
|
||||||
"1045 TG&P",
|
|
||||||
# Free-machining
|
|
||||||
"1144 CF",
|
|
||||||
"1144 HR",
|
|
||||||
"12L14 CF",
|
|
||||||
# Hot-rolled plate/bar
|
|
||||||
"1044 HR",
|
|
||||||
# Stressproof (high-strength shafting)
|
|
||||||
"A311/Stressproof",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Alro shape column header → our MaterialShape enum
|
|
||||||
SHAPE_MAP = {
|
|
||||||
"ROUND": "RoundBar",
|
|
||||||
"FLAT": "FlatBar",
|
|
||||||
"SQUARE": "SquareBar",
|
|
||||||
"ANGLE": "Angle",
|
|
||||||
"CHANNEL": "Channel",
|
|
||||||
"BEAM": "IBeam",
|
|
||||||
"SQ TUBE": "SquareTube",
|
|
||||||
"SQUARE TUBE": "SquareTube",
|
|
||||||
"REC TUBE": "RectangularTube",
|
|
||||||
"RECT TUBE": "RectangularTube",
|
|
||||||
"RECTANGULAR TUBE": "RectangularTube",
|
|
||||||
"ROUND TUBE": "RoundTube",
|
|
||||||
"RND TUBE": "RoundTube",
|
|
||||||
"PIPE": "Pipe",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── ASP.NET control IDs ─────────────────────────────────────────
|
|
||||||
_CP = "ctl00_ContentPlaceHolder1"
|
|
||||||
_PU = f"{_CP}_pnlPopUP"
|
|
||||||
ID = dict(
|
|
||||||
main_grid = f"{_CP}_grdMain",
|
|
||||||
popup_grid = f"{_PU}_grdPopUp",
|
|
||||||
popup_window = f"{_PU}_Window",
|
|
||||||
dims_panel = f"{_PU}_upnlDims",
|
|
||||||
back_btn = f"{_PU}_btnBack",
|
|
||||||
# Dimension dropdowns (cascading: A → B → C → Length)
|
|
||||||
dim_a = f"{_PU}_ddlDimA",
|
|
||||||
dim_b = f"{_PU}_ddlDimB",
|
|
||||||
dim_c = f"{_PU}_ddlDimC",
|
|
||||||
dim_length = f"{_PU}_ddlLength",
|
|
||||||
btn_next = f"{_PU}_btnSearch",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Postback targets ($ separators)
|
|
||||||
PB = dict(
|
|
||||||
main_grid = "ctl00$ContentPlaceHolder1$grdMain",
|
|
||||||
popup_grid = "ctl00$ContentPlaceHolder1$pnlPopUP$grdPopUp",
|
|
||||||
back_btn = "ctl00$ContentPlaceHolder1$pnlPopUP$btnBack",
|
|
||||||
popup = "ctl00$ContentPlaceHolder1$pnlPopUP",
|
|
||||||
dim_a = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimA",
|
|
||||||
dim_b = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimB",
|
|
||||||
dim_c = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Utility helpers
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def parse_fraction(s: str) -> float | None:
|
|
||||||
"""Parse fraction/decimal string → float. '1-1/4' → 1.25, '.250' → 0.25"""
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
s = s.strip().strip('"\'')
|
|
||||||
# Collapse double spaces from Alro dropdown text ("1 1/4" → "1 1/4")
|
|
||||||
s = re.sub(r"\s+", " ", s)
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(s)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# Mixed fraction: "1-1/4" or "1 1/4"
|
|
||||||
m = re.match(r"^(\d+)[\s-](\d+)/(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return int(m[1]) + int(m[2]) / int(m[3])
|
|
||||||
m = re.match(r"^(\d+)/(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return int(m[1]) / int(m[2])
|
|
||||||
m = re.match(r"^(\d+)$", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1])
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def decimal_to_fraction(value: float) -> str:
|
|
||||||
"""0.25 → '1/4', 1.25 → '1-1/4', 3.0 → '3'"""
|
|
||||||
if value <= 0:
|
|
||||||
return "0"
|
|
||||||
whole = int(value)
|
|
||||||
frac = value - whole
|
|
||||||
if abs(frac) < 0.001:
|
|
||||||
return str(whole)
|
|
||||||
from math import gcd
|
|
||||||
sixteenths = round(frac * 16)
|
|
||||||
if sixteenths == 16:
|
|
||||||
return str(whole + 1)
|
|
||||||
g = gcd(sixteenths, 16)
|
|
||||||
num, den = sixteenths // g, 16 // g
|
|
||||||
frac_s = f"{num}/{den}"
|
|
||||||
return f"{whole}-{frac_s}" if whole else frac_s
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_dim_text(s: str) -> str:
|
|
||||||
"""Normalize dimension text: '1 1/4' → '1-1/4', '3/16' → '3/16'"""
|
|
||||||
s = re.sub(r"\s+", " ", s.strip())
|
|
||||||
# "1 1/4" → "1-1/4" (mixed fraction with space → hyphen)
|
|
||||||
s = re.sub(r"^(\d+)\s+(\d+/\d+)$", r"\1-\2", s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def parse_length_to_inches(text: str) -> float | None:
|
|
||||||
"""Parse length string to inches. \"20'\" → 240, \"240\" → 240"""
|
|
||||||
s = text.strip().upper()
|
|
||||||
s = re.sub(r"\s*(RL|RANDOM.*|LENGTHS?|EA|EACH|STOCK)\s*", "", s).strip()
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*['\u2032]", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1]) * 12
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*FT", s)
|
|
||||||
if m:
|
|
||||||
return float(m[1]) * 12
|
|
||||||
m = re.match(r'^(\d+(?:\.\d+)?)\s*"?\s*$', s)
|
|
||||||
if m:
|
|
||||||
v = float(m[1])
|
|
||||||
return v * 12 if v <= 30 else v
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# SmartGrid navigation
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def wait_for_update(page: Page, timeout: int = TIMEOUT):
|
|
||||||
"""Wait for ASP.NET partial postback to finish."""
|
|
||||||
try:
|
|
||||||
await page.wait_for_load_state("networkidle", timeout=timeout)
|
|
||||||
except PwTimeout:
|
|
||||||
log.warning(" networkidle timeout – continuing")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
|
|
||||||
async def do_postback(page: Page, target: str, arg: str):
|
|
||||||
"""Execute a __doPostBack call."""
|
|
||||||
await page.evaluate(f"__doPostBack('{target}', '{arg}')")
|
|
||||||
|
|
||||||
|
|
||||||
async def click_category(page: Page, category: str) -> bool:
|
|
||||||
"""Click a category blue-button for Carbon Steel in the main grid."""
|
|
||||||
log.info(f"Clicking main grid: {category} (row {CS_ROW})")
|
|
||||||
arg = f"{category}${CS_ROW}"
|
|
||||||
link = await page.query_selector(
|
|
||||||
f"#{ID['main_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
|
||||||
)
|
|
||||||
if not link:
|
|
||||||
log.error(f" Button not found for {arg}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
parent = await link.evaluate_handle("el => el.parentElement")
|
|
||||||
await parent.as_element().click()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await page.wait_for_selector(f"#{ID['popup_grid']}", state="visible", timeout=TIMEOUT)
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
except PwTimeout:
|
|
||||||
log.error(f" Popup did not appear for {category}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_popup_grid(page: Page):
|
|
||||||
"""Parse the popup grid → [(grade_name, grade_id, shape, row_idx, has_btn)]."""
|
|
||||||
headers = await page.eval_on_selector_all(
|
|
||||||
f"#{ID['popup_grid']} tr.DataHeader th",
|
|
||||||
"els => els.map(el => el.textContent.trim())",
|
|
||||||
)
|
|
||||||
log.info(f" Popup columns: {headers}")
|
|
||||||
|
|
||||||
rows = await page.query_selector_all(
|
|
||||||
f"#{ID['popup_grid']} tr.griditemP, #{ID['popup_grid']} tr.gridaltItemP"
|
|
||||||
)
|
|
||||||
combos = []
|
|
||||||
for row_idx, row in enumerate(rows):
|
|
||||||
first_td = await row.query_selector("td[gpid]")
|
|
||||||
if not first_td:
|
|
||||||
continue
|
|
||||||
gid = (await first_td.get_attribute("gpid") or "").strip()
|
|
||||||
gname = (await first_td.get_attribute("gpname") or "").strip()
|
|
||||||
tds = await row.query_selector_all("td")
|
|
||||||
for col_idx, td in enumerate(tds):
|
|
||||||
if col_idx == 0:
|
|
||||||
continue
|
|
||||||
shape = headers[col_idx] if col_idx < len(headers) else ""
|
|
||||||
img = await td.query_selector("img[src*='blue_button']")
|
|
||||||
combos.append((gname, gid, shape, row_idx, img is not None))
|
|
||||||
|
|
||||||
active = sum(1 for c in combos if c[4])
|
|
||||||
log.info(f" {active} active grade/shape combos")
|
|
||||||
return combos
|
|
||||||
|
|
||||||
|
|
||||||
async def click_shape(page: Page, shape: str, row_idx: int) -> bool:
|
|
||||||
"""Click a shape button in the popup grid; wait for dims panel."""
|
|
||||||
arg = f"{shape}${row_idx}"
|
|
||||||
link = await page.query_selector(
|
|
||||||
f"#{ID['popup_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
|
||||||
)
|
|
||||||
if not link:
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["popup_grid"], arg)
|
|
||||||
except Exception:
|
|
||||||
log.warning(f" Could not click shape {arg}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
parent = await link.evaluate_handle("el => el.parentElement")
|
|
||||||
await parent.as_element().click()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Wait for the DimA dropdown to appear (the real indicator of dims panel loaded)
|
|
||||||
await page.wait_for_selector(f"#{ID['dim_a']}", state="attached", timeout=TIMEOUT)
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
except PwTimeout:
|
|
||||||
# Check if panel has any content at all
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
if len(html.strip()) > 50:
|
|
||||||
await wait_for_update(page)
|
|
||||||
return True
|
|
||||||
log.warning(f" Dims panel timeout for {arg}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def click_back(page: Page):
|
|
||||||
"""Click Back to return to the popup grid view."""
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["back_btn"], "")
|
|
||||||
await wait_for_update(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Back button error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def close_popup(page: Page):
|
|
||||||
"""Close the popup window and return to the main grid."""
|
|
||||||
try:
|
|
||||||
await do_postback(page, PB["popup"], "Close")
|
|
||||||
await wait_for_update(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Close popup error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Level 3 — Dimension Panel Scraping
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def get_select_options(page: Page, sel_id: str):
|
|
||||||
"""Return [(value, text), ...] for a <select>, excluding placeholders."""
|
|
||||||
el = await page.query_selector(f"#{sel_id}")
|
|
||||||
if not el:
|
|
||||||
return []
|
|
||||||
# Check if disabled
|
|
||||||
disabled = await el.get_attribute("disabled")
|
|
||||||
if disabled:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
opts = await page.eval_on_selector(
|
|
||||||
f"#{sel_id}",
|
|
||||||
"""el => Array.from(el.options).map(o => ({
|
|
||||||
v: o.value, t: o.text.trim(), d: o.disabled
|
|
||||||
}))""",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
(o["v"], o["t"])
|
|
||||||
for o in opts
|
|
||||||
if o["v"] and o["v"] != "-1" and o["t"] and not o["d"]
|
|
||||||
and o["t"].lower() not in ("- select -", "--select--", "select...", "select", "")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_dims_panel(page: Page, grade: str, shape_alro: str,
|
|
||||||
shape_mapped: str, *, save_discovery: bool = False,
|
|
||||||
on_item=None, scraped_dim_a: set[str] | None = None):
|
|
||||||
"""Main Level 3 extraction. Returns list of raw item dicts.
|
|
||||||
|
|
||||||
If on_item callback is provided, it is called with each item dict
|
|
||||||
as soon as it is discovered (for incremental saving).
|
|
||||||
If scraped_dim_a is provided, DimA values in that set are skipped (resume).
|
|
||||||
"""
|
|
||||||
items: list[dict] = []
|
|
||||||
|
|
||||||
if save_discovery:
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
safe = f"{grade}_{shape_alro}".replace(" ", "_").replace("/", "-")
|
|
||||||
await page.screenshot(path=str(SCREENSHOTS_DIR / f"dims_{safe}.png"), full_page=True)
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
(SCREENSHOTS_DIR / f"dims_{safe}.html").write_text(html, encoding="utf-8")
|
|
||||||
log.info(f" Discovery saved → screenshots/dims_{safe}.*")
|
|
||||||
|
|
||||||
# ── Get DimA options (primary dimension: diameter, width, size, etc.) ──
|
|
||||||
dim_a_opts = await get_select_options(page, ID["dim_a"])
|
|
||||||
if not dim_a_opts:
|
|
||||||
log.warning(f" No DimA options found")
|
|
||||||
try:
|
|
||||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
|
||||||
if len(html) > 50:
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
safe = f"{grade}_{shape_alro}_nodimopts".replace(" ", "_").replace("/", "-")
|
|
||||||
(SCREENSHOTS_DIR / f"{safe}.html").write_text(html, encoding="utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" Could not dump dims panel: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
already_done = scraped_dim_a or set()
|
|
||||||
remaining = [(v, t) for v, t in dim_a_opts if v not in already_done]
|
|
||||||
if already_done:
|
|
||||||
log.info(f" DimA: {len(dim_a_opts)} sizes ({len(dim_a_opts) - len(remaining)} already scraped, {len(remaining)} remaining)")
|
|
||||||
else:
|
|
||||||
log.info(f" DimA: {len(dim_a_opts)} sizes")
|
|
||||||
|
|
||||||
# All DimA values already scraped — combo is complete
|
|
||||||
if not remaining:
|
|
||||||
return []
|
|
||||||
|
|
||||||
for a_val, a_text in remaining:
|
|
||||||
# Select DimA → triggers postback → DimB/Length populate
|
|
||||||
await page.select_option(f"#{ID['dim_a']}", a_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
# Check if DimB appeared (secondary dimension: thickness, wall, etc.)
|
|
||||||
dim_b_opts = await get_select_options(page, ID["dim_b"])
|
|
||||||
if dim_b_opts:
|
|
||||||
for b_val, b_text in dim_b_opts:
|
|
||||||
await page.select_option(f"#{ID['dim_b']}", b_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
# Check for DimC (tertiary — rare)
|
|
||||||
dim_c_opts = await get_select_options(page, ID["dim_c"])
|
|
||||||
if dim_c_opts:
|
|
||||||
for c_val, c_text in dim_c_opts:
|
|
||||||
await page.select_option(f"#{ID['dim_c']}", c_val)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
await wait_for_update(page)
|
|
||||||
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, b_val, b_text, c_val, c_text,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
else:
|
|
||||||
# No DimC — read lengths
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, b_val, b_text, None, None,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
else:
|
|
||||||
# No DimB — just DimA + Length
|
|
||||||
lengths = await get_select_options(page, ID["dim_length"])
|
|
||||||
for l_val, l_text in lengths:
|
|
||||||
item = _make_item(
|
|
||||||
grade, shape_mapped,
|
|
||||||
a_val, a_text, None, None, None, None,
|
|
||||||
l_text,
|
|
||||||
)
|
|
||||||
items.append(item)
|
|
||||||
if on_item:
|
|
||||||
on_item(item)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _make_item(grade, shape, a_val, a_text, b_val, b_text, c_val, c_text, l_text):
|
|
||||||
"""Build a raw item dict from dimension selections."""
|
|
||||||
return {
|
|
||||||
"grade": grade,
|
|
||||||
"shape": shape,
|
|
||||||
"dim_a_val": a_val, # decimal string like ".500"
|
|
||||||
"dim_a_text": a_text, # fraction string like "1/2"
|
|
||||||
"dim_b_val": b_val,
|
|
||||||
"dim_b_text": b_text,
|
|
||||||
"dim_c_val": c_val,
|
|
||||||
"dim_c_text": c_text,
|
|
||||||
"length_text": l_text,
|
|
||||||
"length_inches": parse_length_to_inches(l_text),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Output — build catalog JSON
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def build_size_and_dims(shape: str, item: dict):
|
|
||||||
"""Return (size_string, dimensions_dict) for a catalog material entry.
|
|
||||||
|
|
||||||
Uses the decimal values from dropdown option values for precision,
|
|
||||||
and fraction text from dropdown option text for display.
|
|
||||||
"""
|
|
||||||
# Use the numeric value from the dropdown (e.g. ".500") for precision
|
|
||||||
a = float(item["dim_a_val"]) if item.get("dim_a_val") else None
|
|
||||||
b = float(item["dim_b_val"]) if item.get("dim_b_val") else None
|
|
||||||
c = float(item["dim_c_val"]) if item.get("dim_c_val") else None
|
|
||||||
|
|
||||||
a_txt = normalize_dim_text(item.get("dim_a_text") or "")
|
|
||||||
b_txt = normalize_dim_text(item.get("dim_b_text") or "")
|
|
||||||
c_txt = normalize_dim_text(item.get("dim_c_text") or "")
|
|
||||||
|
|
||||||
if shape == "RoundBar" and a is not None:
|
|
||||||
return f'{a_txt}"', {"diameter": round(a, 4)}
|
|
||||||
|
|
||||||
if shape == "FlatBar":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"width": round(a, 4), "thickness": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"width": round(a, 4), "thickness": 0}
|
|
||||||
|
|
||||||
if shape == "SquareBar" and a is not None:
|
|
||||||
return f'{a_txt}"', {"sideLength": round(a, 4)}
|
|
||||||
|
|
||||||
if shape == "Angle":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {a_txt}" x {b_txt}"',
|
|
||||||
{"leg1": round(a, 4), "leg2": round(a, 4), "thickness": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"leg1": round(a, 4), "leg2": round(a, 4), "thickness": 0}
|
|
||||||
|
|
||||||
if shape == "Channel":
|
|
||||||
# Channels may use DimA for combined designation or height
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"height": round(a, 4), "flange": round(b, 4), "web": 0})
|
|
||||||
if a is not None:
|
|
||||||
return a_txt, {"height": round(a, 4), "flange": 0, "web": 0}
|
|
||||||
|
|
||||||
if shape == "IBeam":
|
|
||||||
# DimA might be the W-designation, DimB the weight/ft
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f"W{int(a)} x {b}",
|
|
||||||
{"height": round(a, 4), "weightPerFoot": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f"W{int(a)}", {"height": round(a, 4), "weightPerFoot": 0}
|
|
||||||
|
|
||||||
if shape == "SquareTube":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}" wall',
|
|
||||||
{"sideLength": round(a, 4), "wall": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}"', {"sideLength": round(a, 4), "wall": 0}
|
|
||||||
|
|
||||||
if shape == "RectangularTube":
|
|
||||||
if a is not None and b is not None and c is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}" x {c_txt}" wall',
|
|
||||||
{"width": round(a, 4), "height": round(b, 4), "wall": round(c, 4)})
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" x {b_txt}"',
|
|
||||||
{"width": round(a, 4), "height": round(b, 4), "wall": 0})
|
|
||||||
|
|
||||||
if shape == "RoundTube":
|
|
||||||
if a is not None and b is not None:
|
|
||||||
return (f'{a_txt}" OD x {b_txt}" wall',
|
|
||||||
{"outerDiameter": round(a, 4), "wall": round(b, 4)})
|
|
||||||
if a is not None:
|
|
||||||
return f'{a_txt}" OD', {"outerDiameter": round(a, 4), "wall": 0}
|
|
||||||
|
|
||||||
if shape == "Pipe":
|
|
||||||
sched = b_txt or c_txt or "40"
|
|
||||||
if a is not None:
|
|
||||||
return (f'{a_txt}" NPS Sch {sched}',
|
|
||||||
{"nominalSize": round(a, 4), "schedule": sched})
|
|
||||||
|
|
||||||
# Fallback
|
|
||||||
return a_txt or "", {}
|
|
||||||
|
|
||||||
|
|
||||||
SHAPE_GROUP_KEY = {
|
|
||||||
"Angle": "angles",
|
|
||||||
"Channel": "channels",
|
|
||||||
"FlatBar": "flatBars",
|
|
||||||
"IBeam": "iBeams",
|
|
||||||
"Pipe": "pipes",
|
|
||||||
"RectangularTube": "rectangularTubes",
|
|
||||||
"RoundBar": "roundBars",
|
|
||||||
"RoundTube": "roundTubes",
|
|
||||||
"SquareBar": "squareBars",
|
|
||||||
"SquareTube": "squareTubes",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_catalog(scraped: list[dict]) -> dict:
|
|
||||||
"""Assemble the final catalog JSON from scraped item dicts."""
|
|
||||||
materials: dict[tuple, dict] = {}
|
|
||||||
|
|
||||||
for item in scraped:
|
|
||||||
shape = item.get("shape", "")
|
|
||||||
grade = item.get("grade", "")
|
|
||||||
if not shape or not grade:
|
|
||||||
continue
|
|
||||||
|
|
||||||
size_str, dims = build_size_and_dims(shape, item)
|
|
||||||
key = (shape, grade, size_str)
|
|
||||||
|
|
||||||
if key not in materials:
|
|
||||||
mat = {
|
|
||||||
"type": "Steel",
|
|
||||||
"grade": grade,
|
|
||||||
"size": size_str,
|
|
||||||
"stockItems": [],
|
|
||||||
}
|
|
||||||
mat.update(dims)
|
|
||||||
materials[key] = mat
|
|
||||||
|
|
||||||
length = item.get("length_inches")
|
|
||||||
if length and length > 0:
|
|
||||||
existing = {si["lengthInches"] for si in materials[key]["stockItems"]}
|
|
||||||
if round(length, 4) not in existing:
|
|
||||||
materials[key]["stockItems"].append({
|
|
||||||
"lengthInches": round(length, 4),
|
|
||||||
"quantityOnHand": 0,
|
|
||||||
"supplierOfferings": [{
|
|
||||||
"supplierName": "Alro Steel",
|
|
||||||
"partNumber": "",
|
|
||||||
"supplierDescription": "",
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Group by shape key
|
|
||||||
grouped: dict[str, list] = {v: [] for v in SHAPE_GROUP_KEY.values()}
|
|
||||||
for (shape, _, _), mat in sorted(materials.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])):
|
|
||||||
group_key = SHAPE_GROUP_KEY.get(shape)
|
|
||||||
if group_key:
|
|
||||||
grouped[group_key].append(mat)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"suppliers": [{"name": "Alro Steel"}],
|
|
||||||
"cuttingTools": [
|
|
||||||
{"name": "Bandsaw", "kerfInches": 0.0625, "isDefault": True},
|
|
||||||
{"name": "Chop Saw", "kerfInches": 0.125, "isDefault": False},
|
|
||||||
{"name": "Cold Cut Saw", "kerfInches": 0.0625, "isDefault": False},
|
|
||||||
{"name": "Hacksaw", "kerfInches": 0.0625, "isDefault": False},
|
|
||||||
],
|
|
||||||
"materials": grouped,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Progress management
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def load_progress() -> dict:
|
|
||||||
if PROGRESS_PATH.exists():
|
|
||||||
return json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
|
|
||||||
return {"completed": [], "items": []}
|
|
||||||
|
|
||||||
|
|
||||||
def save_progress(progress: dict):
|
|
||||||
PROGRESS_PATH.write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# Main
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
discover = "--discover" in sys.argv
|
|
||||||
fresh = "--fresh" in sys.argv
|
|
||||||
all_grades = "--all-grades" in sys.argv
|
|
||||||
|
|
||||||
progress = {"completed": [], "items": []} if fresh else load_progress()
|
|
||||||
all_items: list[dict] = progress.get("items", [])
|
|
||||||
done_keys: set[tuple] = {tuple(k) for k in progress.get("completed", [])}
|
|
||||||
|
|
||||||
# Build index of saved DimA values per (grade, shape) for partial resume
|
|
||||||
saved_dim_a: dict[tuple[str, str], set[str]] = {}
|
|
||||||
if all_items and not fresh:
|
|
||||||
for item in all_items:
|
|
||||||
key = (item.get("grade", ""), item.get("shape", ""))
|
|
||||||
saved_dim_a.setdefault(key, set()).add(item.get("dim_a_val", ""))
|
|
||||||
|
|
||||||
log.info("Alro Steel SmartGrid Scraper")
|
|
||||||
if all_grades:
|
|
||||||
log.info(" Mode: ALL grades")
|
|
||||||
else:
|
|
||||||
log.info(f" Filtering to {len(GRADE_FILTER)} grades: {', '.join(sorted(GRADE_FILTER))}")
|
|
||||||
if fresh:
|
|
||||||
log.info(" Fresh start — ignoring saved progress")
|
|
||||||
elif done_keys:
|
|
||||||
log.info(f" Resuming: {len(done_keys)} combos done, {len(all_items)} items saved")
|
|
||||||
if discover:
|
|
||||||
log.info(" Discovery mode — will scrape first item then stop")
|
|
||||||
|
|
||||||
async with Stealth().use_async(async_playwright()) as pw:
|
|
||||||
browser = await pw.chromium.launch(headless=False)
|
|
||||||
ctx = await browser.new_context(
|
|
||||||
viewport={"width": 1280, "height": 900},
|
|
||||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
||||||
locale="en-US",
|
|
||||||
timezone_id="America/Indiana/Indianapolis",
|
|
||||||
)
|
|
||||||
page = await ctx.new_page()
|
|
||||||
|
|
||||||
log.info(f"Navigating to SmartGrid …")
|
|
||||||
await page.goto(BASE_URL, wait_until="networkidle", timeout=30_000)
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
if not await page.query_selector(f"#{ID['main_grid']}"):
|
|
||||||
log.error("Main grid not found! Saving screenshot.")
|
|
||||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
|
||||||
await page.screenshot(path=str(SCREENSHOTS_DIR / "error_no_grid.png"))
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
log.info("Main grid loaded")
|
|
||||||
total_scraped = 0
|
|
||||||
first_item = True
|
|
||||||
|
|
||||||
for category in CATEGORIES:
|
|
||||||
log.info(f"\n{'=' * 60}")
|
|
||||||
log.info(f" Category: {category}")
|
|
||||||
log.info(f"{'=' * 60}")
|
|
||||||
|
|
||||||
if not await click_category(page, category):
|
|
||||||
continue
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
combos = await scrape_popup_grid(page)
|
|
||||||
|
|
||||||
for grade_name, grade_id, shape_name, row_idx, has_btn in combos:
|
|
||||||
if not has_btn:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Grade filter
|
|
||||||
if not all_grades and grade_name not in GRADE_FILTER:
|
|
||||||
continue
|
|
||||||
|
|
||||||
shape_upper = shape_name.upper().strip()
|
|
||||||
shape_mapped = SHAPE_MAP.get(shape_upper)
|
|
||||||
if shape_mapped is None:
|
|
||||||
log.info(f" Skip unmapped shape: {shape_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
combo_key = (category, grade_name, shape_name)
|
|
||||||
if combo_key in done_keys:
|
|
||||||
log.info(f" Skip (done): {grade_name} / {shape_name}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.info(f"\n -- {grade_name} / {shape_name} -> {shape_mapped} --")
|
|
||||||
|
|
||||||
if not await click_shape(page, shape_name, row_idx):
|
|
||||||
await click_back(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
continue
|
|
||||||
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
combo_count = 0
|
|
||||||
def on_item_discovered(item):
|
|
||||||
nonlocal total_scraped, combo_count
|
|
||||||
all_items.append(item)
|
|
||||||
total_scraped += 1
|
|
||||||
combo_count += 1
|
|
||||||
progress["items"] = all_items
|
|
||||||
save_progress(progress)
|
|
||||||
|
|
||||||
# Pass already-scraped DimA values so partial combos resume correctly
|
|
||||||
already = saved_dim_a.get((grade_name, shape_mapped), set())
|
|
||||||
|
|
||||||
items = await scrape_dims_panel(
|
|
||||||
page, grade_name, shape_name, shape_mapped,
|
|
||||||
save_discovery=first_item or discover,
|
|
||||||
on_item=on_item_discovered,
|
|
||||||
scraped_dim_a=already,
|
|
||||||
)
|
|
||||||
first_item = False
|
|
||||||
|
|
||||||
log.info(f" -> {combo_count} items (total {total_scraped})")
|
|
||||||
|
|
||||||
done_keys.add(combo_key)
|
|
||||||
progress["completed"] = [list(k) for k in done_keys]
|
|
||||||
save_progress(progress)
|
|
||||||
|
|
||||||
await click_back(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
if discover:
|
|
||||||
log.info("\nDiscovery done. Check: scripts/AlroCatalog/screenshots/")
|
|
||||||
await browser.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
await close_popup(page)
|
|
||||||
await asyncio.sleep(DELAY)
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
# ── Build output ──
|
|
||||||
log.info(f"\n{'=' * 60}")
|
|
||||||
log.info(f"Building catalog from {len(all_items)} items …")
|
|
||||||
catalog = build_catalog(all_items)
|
|
||||||
|
|
||||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
OUTPUT_PATH.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
|
|
||||||
log.info(f"Written: {OUTPUT_PATH}")
|
|
||||||
total_mats = sum(len(v) for v in catalog["materials"].values())
|
|
||||||
total_stock = sum(len(m["stockItems"]) for v in catalog["materials"].values() for m in v)
|
|
||||||
log.info(f"Materials: {total_mats}")
|
|
||||||
log.info(f"Stock items: {total_stock}")
|
|
||||||
for shape_key, mats in sorted(catalog["materials"].items()):
|
|
||||||
if mats:
|
|
||||||
log.info(f" {shape_key}: {len(mats)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Focused regression checks for split part-number and length cut badges."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
|
||||||
|
|
||||||
|
class CutBadgeFormatTests(unittest.TestCase):
|
||||||
|
def test_named_cut_badge_renders_part_number_and_length_in_separate_sections(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
cuts_cell_start = markup.index("@foreach (var item in entry.Bin.Items)", results_start)
|
||||||
|
cut_rows_start = markup.rfind("<td>", results_start, cuts_cell_start)
|
||||||
|
cut_rows = markup[cut_rows_start:markup.index("</td>", cuts_cell_start)]
|
||||||
|
|
||||||
|
self.assertIn('class="badge me-1 mb-1 fs-6 cut-part-badge"', cut_rows)
|
||||||
|
self.assertIn('class="cut-part-badge-name"', cut_rows)
|
||||||
|
self.assertIn('class="cut-part-badge-length"', cut_rows)
|
||||||
|
self.assertIn('@item.Name', cut_rows)
|
||||||
|
self.assertIn('@FormatResultLength(item.Length)', cut_rows)
|
||||||
|
self.assertNotIn('$"{item.Name} (', cut_rows)
|
||||||
|
|
||||||
|
def test_length_section_has_a_darker_background_than_the_part_number_section(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
|
||||||
|
self.assertIn('.cut-part-badge {', css)
|
||||||
|
self.assertIn('.cut-part-badge-name {', css)
|
||||||
|
self.assertIn('.cut-part-badge-length {', css)
|
||||||
|
self.assertIn('background-color: #4c6680;', css)
|
||||||
|
|
||||||
|
def test_printed_cut_badges_keep_part_numbers_and_separators_legible(self) -> None:
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
print_styles = report_css[report_css.index("@media print {"):]
|
||||||
|
|
||||||
|
self.assertIn(".cut-part-badge {", print_styles)
|
||||||
|
self.assertIn("color: #000 !important;", print_styles)
|
||||||
|
self.assertIn(".cut-part-badge-divider {", print_styles)
|
||||||
|
self.assertIn("border-left: 1px solid #000 !important;", print_styles)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Regression checks for compact CutList identifiers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_SERVICE = REPO_ROOT / "CutList.Web/Services/JobService.cs"
|
||||||
|
UNPAD_MIGRATION = REPO_ROOT / "CutList.Web/Migrations/20260802212500_RemoveJobNumberPadding.cs"
|
||||||
|
|
||||||
|
|
||||||
|
class JobNumberFormatTests(unittest.TestCase):
|
||||||
|
def test_new_jobs_use_the_database_id_without_zero_padding(self) -> None:
|
||||||
|
service = JOB_SERVICE.read_text()
|
||||||
|
|
||||||
|
self.assertIn('job.JobNumber = $"JOB-{job.Id}";', service)
|
||||||
|
self.assertNotIn("D5", service)
|
||||||
|
self.assertNotIn("MaxAsync() as string", service)
|
||||||
|
|
||||||
|
def test_existing_job_numbers_are_converted_to_their_database_ids(self) -> None:
|
||||||
|
migration = UNPAD_MIGRATION.read_text()
|
||||||
|
|
||||||
|
self.assertIn("SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))", migration)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Focused regression checks for job-level actions on the Results tab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||||
|
|
||||||
|
|
||||||
|
class JobResultsActionsTests(unittest.TestCase):
|
||||||
|
def test_lock_job_is_in_the_results_action_row_before_result_cards(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
result_cards_start = markup.index("@if (packResult != null && summary != null)", results_start)
|
||||||
|
action_row = markup[results_start:result_cards_start]
|
||||||
|
|
||||||
|
self.assertIn('@onclick="PrintReport"', action_row)
|
||||||
|
self.assertIn('@onclick="LockJob"', action_row)
|
||||||
|
self.assertIn("Lock Job", action_row)
|
||||||
|
|
||||||
|
def test_print_report_hides_job_lock_status(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
|
||||||
|
self.assertIn('class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only"', markup)
|
||||||
|
self.assertIn('<span class="badge bg-success print-screen-only">', markup)
|
||||||
|
self.assertIn(".print-screen-only", report_css)
|
||||||
|
self.assertIn("display: none !important;", report_css)
|
||||||
|
|
||||||
|
def test_print_report_forces_backgrounds_to_plain_white(self) -> None:
|
||||||
|
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||||
|
print_styles = report_css[report_css.index("@media print {"):]
|
||||||
|
|
||||||
|
self.assertIn("*::before", print_styles)
|
||||||
|
self.assertIn("background: transparent !important;", print_styles)
|
||||||
|
self.assertIn("box-shadow: none !important;", print_styles)
|
||||||
|
|
||||||
|
def test_print_report_shows_the_selected_cutting_method_and_kerf(self) -> None:
|
||||||
|
markup = JOB_EDITOR.read_text()
|
||||||
|
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||||
|
summary_start = markup.index('<div class="row mb-4 print-summary">', results_start)
|
||||||
|
material_list_start = markup.index('<!-- Material List:', summary_start)
|
||||||
|
report_summary = markup[summary_start:material_list_start]
|
||||||
|
|
||||||
|
self.assertIn('class="print-cut-method"', report_summary)
|
||||||
|
self.assertIn("<strong>Cut Method:</strong>", report_summary)
|
||||||
|
self.assertIn("@job.CuttingTool.Name", report_summary)
|
||||||
|
self.assertIn("Kerf:", report_summary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Regression checks for the CutList navigation brand on its light header."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
NAV_MENU = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor"
|
||||||
|
NAV_MENU_CSS = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor.css"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
APP_SHELL = REPO_ROOT / "CutList.Web/Components/App.razor"
|
||||||
|
|
||||||
|
|
||||||
|
def selector_color(css: str, selector: str) -> str:
|
||||||
|
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css)
|
||||||
|
if match is None:
|
||||||
|
raise AssertionError(f"Missing CSS selector: {selector}")
|
||||||
|
|
||||||
|
color = re.search(r"\bcolor:\s*(#[0-9a-fA-F]{6})", match.group(1))
|
||||||
|
if color is None:
|
||||||
|
raise AssertionError(f"Missing hex color for selector: {selector}")
|
||||||
|
return color.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_ratio(foreground: str, background: str) -> float:
|
||||||
|
def luminance(color: str) -> float:
|
||||||
|
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||||
|
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels]
|
||||||
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||||
|
|
||||||
|
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
class NavMenuBrandContrastTests(unittest.TestCase):
|
||||||
|
def test_list_word_has_accessible_contrast_against_light_top_row(self) -> None:
|
||||||
|
list_color = selector_color(NAV_MENU_CSS.read_text(), ".navbar-brand .brand-list")
|
||||||
|
top_row_color = selector_color(APP_CSS.read_text(), ".top-row")
|
||||||
|
|
||||||
|
self.assertGreaterEqual(contrast_ratio(list_color, top_row_color), 4.5)
|
||||||
|
|
||||||
|
def test_brand_keeps_cut_and_list_as_adjacent_words(self) -> None:
|
||||||
|
markup = NAV_MENU.read_text()
|
||||||
|
self.assertIn('<span class="brand-cut">Cut</span><span class="brand-list">List</span>', markup)
|
||||||
|
|
||||||
|
def test_visual_system_uses_the_industrial_blue_accent(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn("--accent: #2b75a8;", css)
|
||||||
|
self.assertIn("--navy: #203747;", css)
|
||||||
|
self.assertNotIn("--orange:", css)
|
||||||
|
|
||||||
|
def test_desktop_content_padding_does_not_push_the_nav_brand_right(self) -> None:
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertNotIn(".top-row, article {", css)
|
||||||
|
|
||||||
|
def test_nav_brand_is_left_aligned_with_the_navigation_items(self) -> None:
|
||||||
|
css = NAV_MENU_CSS.read_text()
|
||||||
|
self.assertIn(".top-row { justify-content: flex-start;", css)
|
||||||
|
|
||||||
|
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
|
||||||
|
shell = APP_SHELL.read_text()
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn("family=IBM+Plex+Sans", shell)
|
||||||
|
self.assertIn('font-family: "IBM Plex Sans",', css)
|
||||||
|
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
|
||||||
|
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
|
||||||
|
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
|
||||||
|
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Focused regression checks for the data-backed CutList overview."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||||
|
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||||
|
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||||
|
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewDashboardTests(unittest.TestCase):
|
||||||
|
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||||
|
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||||
|
source = OVERVIEW_SERVICE.read_text()
|
||||||
|
self.assertIn("GetSnapshotAsync", source)
|
||||||
|
self.assertIn("context.Jobs", source)
|
||||||
|
self.assertIn("context.JobStocks", source)
|
||||||
|
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||||
|
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
|
||||||
|
|
||||||
|
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||||
|
markup = HOME_PAGE.read_text()
|
||||||
|
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||||
|
self.assertIn("Recently created jobs", markup)
|
||||||
|
self.assertIn("Frequently specified stock", markup)
|
||||||
|
self.assertIn("Based on stock configured on jobs", markup)
|
||||||
|
self.assertIn("overview.RecentJobs", markup)
|
||||||
|
self.assertIn("overview.FrequentStock", markup)
|
||||||
|
|
||||||
|
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
|
||||||
|
source = OVERVIEW_SERVICE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
|
||||||
|
|
||||||
|
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||||
|
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||||
|
css = APP_CSS.read_text()
|
||||||
|
self.assertIn(".overview-grid", css)
|
||||||
|
self.assertIn(".overview-stock-list", css)
|
||||||
|
self.assertIn(".overview-recent-jobs", css)
|
||||||
|
|
||||||
|
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||||
|
markup = HOME_PAGE.read_text()
|
||||||
|
self.assertIn("loadError", markup)
|
||||||
|
self.assertIn("Overview data is unavailable", markup)
|
||||||
|
self.assertIn("catch (Exception ex)", markup)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user