Compare commits

...
22 Commits
Author SHA1 Message Date
ajandClaude Opus 4.6 baf7f1170e chore(web): simplify overview header and ignore Python test cache
Build CutList image / build-and-push (push) Successful in 44s
Drop the redundant eyebrow/heading/subtext from the Home overview
header, leaving just the action buttons since the page title already
conveys the same info. Also ignore tests/__pycache__ so local pytest
bytecode cache doesn't get tracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:16:37 -04:00
aj 9b1564451d Remove unused workspace-header from MainLayout
Build CutList image / build-and-push (push) Successful in 22s
It was static markup (fixed "Cut planning workspace" / "Ready to plan"
text) that never varied by page or state, so it was pure dead weight.
2026-08-03 07:53:15 -04:00
aj 5409479e00 fix(web): make printed cut instructions legible
Build CutList image / build-and-push (push) Successful in 19s
Keep part numbers and their dividers visible in monochrome print output, and include the selected cutting tool and kerf on the printed report.
2026-08-03 02:18:45 +00:00
aj 25af9e3ba8 fix(web): keep printed cut-list rows together
Build CutList image / build-and-push (push) Successful in 22s
2026-08-03 01:51:11 +00:00
aj 3ccaddef0c [verified] feat: add configurable results length units
Build CutList image / build-and-push (push) Successful in 31s
Default cut results to total-inch mixed fractions, allow switching to feet and inches, and separate part names from lengths visually.
2026-08-03 01:31:09 +00:00
aj 79bf56afd8 docs: clarify job lock workflow 2026-08-03 01:09:58 +00:00
aj 96f5824d12 fix(web): simplify printed cut reports
Build CutList image / build-and-push (push) Successful in 19s
2026-08-03 01:08:43 +00:00
aj db054aa6dc fix(web): hide job lock status from printed reports
Build CutList image / build-and-push (push) Successful in 21s
2026-08-03 00:26:30 +00:00
aj 8e2027d81b feat(web): split cut badge lengths
Build CutList image / build-and-push (push) Successful in 21s
2026-08-03 00:09:44 +00:00
aj 57c7aa5f9f feat(web): use compact cut list identifiers
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 21:33:29 +00:00
aj 34c9b77501 fix(web): place lock action in results toolbar
Build CutList image / build-and-push (push) Successful in 20s
2026-08-02 21:19:36 +00:00
aj 203ea0c69d style(web): compact CutList typography
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:57:17 +00:00
aj 5cbd06711c fix(web): load overview stock summary with scalar projection
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:24:04 +00:00
aj f3b911f37c feat(web): add data-backed planning overview
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:18:07 +00:00
aj 1f04c5343a feat(web): refresh CutList visual system
Build CutList image / build-and-push (push) Successful in 24s
2026-08-02 19:57:57 +00:00
aj 3fbcb05d4d fix(web): restore CutList brand contrast 2026-08-02 19:45:32 +00:00
aj 4f21d536f8 fix(web): label required lengths as material list
Build CutList image / build-and-push (push) Successful in 27s
2026-08-02 18:32:45 +00:00
aj c1409bbdfb fix(web): reduce job title heading size
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 14:32:39 +00:00
aj 7882ed7669 fix(web): keep table action buttons inline
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 14:26:56 +00:00
aj af37789dcd Remove Alro catalog scraper and seed data
Build CutList image / build-and-push (push) Successful in 20s
Standard available lengths are now sourced from Oneal instead, so the
Alro scraping workflow and its generated seed data are no longer needed.
2026-08-02 10:00:44 -04:00
aj ebe725579b Improve CutList brand contrast
Build CutList image / build-and-push (push) Successful in 20s
2026-08-02 09:53:25 -04:00
aj 05179aa445 Redesign CutList workspace UI
Build CutList image / build-and-push (push) Successful in 25s
2026-08-02 09:12:04 -04:00
31 changed files with 865 additions and 2140 deletions
+5 -6
View File
@@ -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
+14 -6
View File
@@ -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);
}
}
+8
View File
@@ -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)}\"";
}
} }
} }
+3
View File
@@ -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>
+11 -8
View File
@@ -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>
+20 -119
View File
@@ -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;
}
}
+137 -43
View File
@@ -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 class="lead">1D Bin Packing Optimization for Material Cutting</p> <p><em>Loading overview…</em></p>
}
<div class="row mt-4"> else
<div class="col-md-6 col-lg-4 mb-4"> {
<div class="card h-100"> <section class="overview-header">
<div class="card-body"> <div class="overview-actions">
<h5 class="card-title">Jobs</h5> <a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p> <a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
</div>
</div> </div>
</div> </section>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Materials</h5>
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-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" /> @if (loadError is not null)
{
<div class="alert alert-warning mt-4" role="alert">
<strong>Overview data is unavailable.</strong> @loadError
</div>
}
<h4>How It Works</h4> <section class="overview-stats" aria-label="CutList statistics">
<ol> <a href="jobs" class="overview-stat">
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li> <span class="overview-stat-label">Jobs</span>
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li> <strong>@overview.TotalJobs</strong>
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li> <small>@overview.OpenJobs open for planning</small>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li> </a>
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li> <a href="jobs" class="overview-stat">
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li> <span class="overview-stat-label">Parts required</span>
</ol> <strong>@overview.TotalParts</strong>
<small>Across all current jobs</small>
</a>
<a href="stock" class="overview-stat">
<span class="overview-stat-label">Stock options</span>
<strong>@overview.ActiveStockItems</strong>
<small>Active catalog lengths</small>
</a>
</section>
<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>
<article class="overview-panel overview-stock-panel">
<div class="overview-panel-heading">
<div>
<p class="eyebrow">Planning signal</p>
<h2>Frequently specified stock</h2>
</div>
<a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
</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>
}
</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;
}
}
}
+53 -28
View File
@@ -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.
@@ -503,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
@@ -511,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()
{ {
@@ -686,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-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button> <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>
</div>
} }
</td> </td>
</tr> </tr>
@@ -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-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button> <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>
</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++;
} }
@@ -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>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a> <div class="table-actions">
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button> <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-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></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>
</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>
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button> <div class="table-actions">
<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-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>
</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-%';
");
}
}
}
+1
View File
@@ -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>();
+9 -24
View File
@@ -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)
{ {
+92
View File
@@ -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);
+95 -3
View File
@@ -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; } }
+40 -4
View File
@@ -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
View File
@@ -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
-83
View File
@@ -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
}
]
}
-790
View File
@@ -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())
+47
View File
@@ -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()
+28
View File
@@ -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()
+54
View File
@@ -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()
+74
View File
@@ -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()
+57
View File
@@ -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()