Compare commits
40
Commits
7b210f518b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baf7f1170e | ||
|
|
9b1564451d | ||
|
|
5409479e00 | ||
|
|
25af9e3ba8 | ||
|
|
3ccaddef0c | ||
|
|
79bf56afd8 | ||
|
|
96f5824d12 | ||
|
|
db054aa6dc | ||
|
|
8e2027d81b | ||
|
|
57c7aa5f9f | ||
|
|
34c9b77501 | ||
|
|
203ea0c69d | ||
|
|
5cbd06711c | ||
|
|
f3b911f37c | ||
|
|
1f04c5343a | ||
|
|
3fbcb05d4d | ||
|
|
4f21d536f8 | ||
|
|
c1409bbdfb | ||
|
|
7882ed7669 | ||
|
|
af37789dcd | ||
|
|
ebe725579b | ||
|
|
05179aa445 | ||
|
|
ec16dd2c9b | ||
|
|
0c5ecf716a | ||
|
|
c66099481b | ||
|
|
9020ba80c1 | ||
|
|
2cb9c5ec8d | ||
|
|
232b86f15b | ||
|
|
ec52834cd4 | ||
|
|
d8656cc454 | ||
|
|
0333942cb3 | ||
|
|
49a539d7e5 | ||
|
|
6bdbdf4969 | ||
|
|
383fa19cf2 | ||
|
|
843ba24788 | ||
|
|
41283987b9 | ||
|
|
41dda11062 | ||
|
|
f71cf8ab0a | ||
|
|
fd6e8d632c | ||
|
|
69c993fcb2 |
+4
-5
@@ -244,13 +244,12 @@ ModelManifest.xml
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# AlroCatalog scraper output
|
||||
scripts/AlroCatalog/__pycache__/
|
||||
scripts/AlroCatalog/pdfs/
|
||||
scripts/AlroCatalog/screenshots/
|
||||
|
||||
# Superpowers subagent-driven-development scratch workspace
|
||||
.superpowers/
|
||||
|
||||
# Playwright MCP browser session logs/traces
|
||||
.playwright-mcp/
|
||||
|
||||
# Python bytecode cache (tests/)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -12,9 +12,9 @@ The solution contains four projects:
|
||||
|
||||
| Project | Framework | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||
| **CutList.Core** | .NET 8.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** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||
| **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
||||
| **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 |
|
||||
|
||||
**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`.
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
@@ -120,13 +128,10 @@ Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's R
|
||||
Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its own standalone table (`DimAngle`, `DimChannel`, `DimFlatBar`, `DimIBeam`, `DimPipe`, `DimRectangularTube`, `DimRoundBar`, `DimRoundTube`, `DimSquareBar`, `DimSquareTube`) with no base table. Each table has its own `Id` (shared sequence) and `MaterialId` FK. Each generates its own `SizeString` and `SortOrder`.
|
||||
|
||||
### StockItem
|
||||
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
|
||||
- `MaterialId`, `LengthInches` (decimal), `IsActive`
|
||||
- **Unique constraint**: (MaterialId, LengthInches)
|
||||
- **Relationships**: `Material`, `Transactions` (1:many StockTransaction)
|
||||
|
||||
### StockTransaction
|
||||
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
|
||||
- Optional: `JobId`
|
||||
- **Relationships**: `Material`
|
||||
- No quantity tracking — a StockItem represents a length of material you can cut from, not a counted inventory record
|
||||
|
||||
### CuttingTool
|
||||
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
|
||||
@@ -154,8 +159,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
|
||||
### StockItemService
|
||||
- CRUD with soft delete
|
||||
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
|
||||
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
|
||||
|
||||
### JobService
|
||||
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
|
||||
@@ -167,7 +170,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
|
||||
### CutListPackingService
|
||||
- `PackAsync(parts, kerfInches, jobStock?)` — runs optimization per material group
|
||||
- Separates results into `InStockBins` (from inventory) and `ToBePurchasedBins`
|
||||
- Separates results into `InStockBins` (from catalog-sourced job stock) and `ToBePurchasedBins`
|
||||
- `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
|
||||
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.)
|
||||
|
||||
@@ -176,7 +179,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
|
||||
### CatalogService
|
||||
- `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
|
||||
|
||||
@@ -188,7 +191,7 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
| `/jobs/{Id}` | Jobs/Edit | Tabbed editor (Details, Parts, Stock, Results); locked jobs show banner + disable editing |
|
||||
| `/materials` | Materials/Index | Material list with MaterialFilter, pagination |
|
||||
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) |
|
||||
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
|
||||
| `/stock` | Stock/Index | Stock items with MaterialFilter, pagination |
|
||||
| `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
|
||||
| `/tools` | Tools/Index | Cutting tools CRUD |
|
||||
| `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) |
|
||||
@@ -211,9 +214,10 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
- **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
|
||||
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
|
||||
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
||||
- **Job stock** — Jobs can use auto-discovered inventory OR define custom stock lengths
|
||||
- **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
|
||||
- **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
|
||||
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
||||
- **Priority system** — Lower priority bins used first in packing algorithm
|
||||
@@ -223,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)
|
||||
- `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
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using CutList.Core.Formatting;
|
||||
using Xunit;
|
||||
|
||||
namespace CutList.Core.Tests;
|
||||
|
||||
public class ResultsLengthDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(150, "150\"")]
|
||||
[InlineData(150.375, "150-3/8\"")]
|
||||
public void FormatInches_uses_total_inches_without_converting_to_feet(double inches, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ArchUnits.FormatInches(inches));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Results_markup_includes_unit_toggle_and_divider_between_part_and_length()
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/Components/Pages/Jobs/Edit.razor"));
|
||||
var markup = File.ReadAllText(sourcePath);
|
||||
|
||||
Assert.Contains("FormatResultLength", markup);
|
||||
Assert.Contains("cut-part-badge-divider", markup);
|
||||
Assert.Contains("Show feet + inches", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Printed_cut_list_rows_are_kept_together()
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/wwwroot/css/report.css"));
|
||||
var css = File.ReadAllText(sourcePath);
|
||||
|
||||
Assert.Contains(".cutlist-material-card tbody tr", css);
|
||||
Assert.Contains("break-inside: avoid", css);
|
||||
Assert.Contains("page-break-inside: avoid", css);
|
||||
}
|
||||
}
|
||||
@@ -80,5 +80,13 @@ namespace CutList.Core.Formatting
|
||||
return $"{inches}\"";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)}\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,14 +70,13 @@ public class ApiClient
|
||||
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? [];
|
||||
}
|
||||
|
||||
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, int quantityOnHand, string? notes)
|
||||
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, string? notes)
|
||||
{
|
||||
var body = new
|
||||
{
|
||||
MaterialId = materialId,
|
||||
Length = length,
|
||||
Name = name,
|
||||
QuantityOnHand = quantityOnHand,
|
||||
Notes = notes
|
||||
};
|
||||
var response = await _http.PostAsJsonAsync("api/stock-items", body);
|
||||
@@ -360,7 +359,6 @@ public class ApiStockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ public class InventoryTools
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = s.LengthFormatted,
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive
|
||||
}).ToList()
|
||||
@@ -194,14 +193,12 @@ public class InventoryTools
|
||||
string length,
|
||||
[Description("Optional name/label for this stock item")]
|
||||
string? name = null,
|
||||
[Description("Initial quantity on hand (default 0)")]
|
||||
int quantityOnHand = 0,
|
||||
[Description("Notes")]
|
||||
string? notes = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, quantityOnHand, notes);
|
||||
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, notes);
|
||||
|
||||
if (stockItem == null)
|
||||
return new StockItemResult { Success = false, Error = "Failed to create stock item" };
|
||||
@@ -217,7 +214,6 @@ public class InventoryTools
|
||||
LengthInches = stockItem.LengthInches,
|
||||
LengthFormatted = stockItem.LengthFormatted,
|
||||
Name = stockItem.Name,
|
||||
QuantityOnHand = stockItem.QuantityOnHand,
|
||||
Notes = stockItem.Notes,
|
||||
IsActive = stockItem.IsActive
|
||||
}
|
||||
@@ -237,7 +233,7 @@ public class InventoryTools
|
||||
|
||||
#region Convenience
|
||||
|
||||
[McpServerTool(Name = "add_stock"), Description("Convenience method: adds a material (if needed) and a stock item (if needed) with an initial quantity, all in one call.")]
|
||||
[McpServerTool(Name = "add_stock"), Description("Convenience method: adds a material (if needed) and a stock item (if needed), all in one call.")]
|
||||
public async Task<AddStockResult> AddStock(
|
||||
[Description("Material shape (e.g., 'Angle', 'FlatBar')")]
|
||||
string shape,
|
||||
@@ -245,8 +241,6 @@ public class InventoryTools
|
||||
string size,
|
||||
[Description("Stock length (e.g., '20'', '240')")]
|
||||
string length,
|
||||
[Description("Quantity on hand (default 0)")]
|
||||
int quantityOnHand = 0,
|
||||
[Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")]
|
||||
string type = "Steel",
|
||||
[Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")]
|
||||
@@ -315,7 +309,7 @@ public class InventoryTools
|
||||
{
|
||||
try
|
||||
{
|
||||
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, quantityOnHand, null);
|
||||
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, null);
|
||||
stockItemCreated = true;
|
||||
}
|
||||
catch (ApiConflictException)
|
||||
@@ -351,8 +345,7 @@ public class InventoryTools
|
||||
MaterialCreated = materialCreated,
|
||||
StockItemId = stockItem.Id,
|
||||
StockItemCreated = stockItemCreated,
|
||||
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
|
||||
QuantityOnHand = stockItem.QuantityOnHand
|
||||
LengthFormatted = ArchUnits.FormatFromInches(lengthInches)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -519,7 +512,6 @@ public class StockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -548,7 +540,6 @@ public class AddStockResult
|
||||
public int StockItemId { get; set; }
|
||||
public bool StockItemCreated { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public int QuantityOnHand { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -257,9 +257,9 @@ public class JobTools
|
||||
string length,
|
||||
[Description("Quantity available (-1 for unlimited, default -1)")]
|
||||
int quantity = -1,
|
||||
[Description("Stock item ID from inventory (optional - links to tracked inventory)")]
|
||||
[Description("Stock item ID from the stock catalog (optional - links to a specific catalog stock item)")]
|
||||
int? stockItemId = null,
|
||||
[Description("True if this is a custom length not from inventory (default false)")]
|
||||
[Description("True if this is a custom length not sourced from the stock catalog (default false)")]
|
||||
bool isCustomLength = false,
|
||||
[Description("Priority - lower number = used first (default 10)")]
|
||||
int priority = 10)
|
||||
@@ -304,7 +304,7 @@ public class JobTools
|
||||
|
||||
#region Optimization
|
||||
|
||||
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined. If stock is defined, it will be used; otherwise the optimizer uses available inventory. Returns optimized cut layouts per material with efficiency stats.")]
|
||||
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined, and stock must be explicitly configured on the job (via add_job_stock) for each material used by its parts - there is no fallback to inventory; parts with no matching stock configured come back as items not placed. Returns optimized cut layouts per material with efficiency stats.")]
|
||||
public async Task<OptimizeJobResult> OptimizeJob(
|
||||
[Description("Job ID")]
|
||||
int jobId,
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<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="css/app.css" />
|
||||
<link rel="stylesheet" href="css/report.css" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<article class="content px-4">
|
||||
<article class="content">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">CutList</a>
|
||||
<div class="top-row">
|
||||
<div class="brand-lockup">
|
||||
<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>
|
||||
|
||||
@@ -8,29 +9,31 @@
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="flex-column">
|
||||
<p class="nav-section-label">Workspace</p>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
<i class="bi bi-grid-1x2-fill" aria-hidden="true"></i> Overview
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<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>
|
||||
</div>
|
||||
<p class="nav-section-label">Library</p>
|
||||
<div class="nav-item px-3">
|
||||
<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>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="stock">
|
||||
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
|
||||
<i class="bi bi-rulers" aria-hidden="true"></i> Stock Items
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tools">
|
||||
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
|
||||
<i class="bi bi-tools" aria-hidden="true"></i> Cutting Tools
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,119 +1,20 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-check-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-check' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3.854 2.146a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 3.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 7.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 0 1 .708-.708l.146.147 1.146-1.147a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-box-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box' viewBox='0 0 16 16'%3E%3Cpath d='M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-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;
|
||||
}
|
||||
}
|
||||
.top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
|
||||
.brand-lockup { display: flex; align-items: center; gap: .75rem; }
|
||||
.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; }
|
||||
.brand-mark i { transform: rotate(8deg); }
|
||||
.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; }
|
||||
.navbar-brand .brand-cut { color: #2b75a8; }
|
||||
.navbar-brand .brand-list { color: #17212b; }
|
||||
.navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
|
||||
.nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
|
||||
.nav-item { padding: .1rem .75rem; }
|
||||
.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; }
|
||||
.nav-item ::deep a.active { background: #294655; color: #fff; }
|
||||
.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: '☰'; }
|
||||
.nav-scrollable { display: none; }
|
||||
.navbar-toggler:checked ~ .nav-scrollable { display: block; }
|
||||
@media (min-width: 641px) { .navbar-toggler { display: none; } .nav-scrollable { display: block; } }
|
||||
|
||||
@@ -1,49 +1,143 @@
|
||||
@page "/"
|
||||
@inject OverviewService OverviewService
|
||||
@using CutList.Core.Formatting
|
||||
|
||||
<PageTitle>CutList - Home</PageTitle>
|
||||
<PageTitle>CutList - Overview</PageTitle>
|
||||
|
||||
<h1>CutList</h1>
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading overview…</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="overview-header">
|
||||
<div class="overview-actions">
|
||||
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
|
||||
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
|
||||
@if (loadError is not null)
|
||||
{
|
||||
<div class="alert alert-warning mt-4" role="alert">
|
||||
<strong>Overview data is unavailable.</strong> @loadError
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Jobs</h5>
|
||||
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
|
||||
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<section class="overview-stats" aria-label="CutList statistics">
|
||||
<a href="jobs" class="overview-stat">
|
||||
<span class="overview-stat-label">Jobs</span>
|
||||
<strong>@overview.TotalJobs</strong>
|
||||
<small>@overview.OpenJobs open for planning</small>
|
||||
</a>
|
||||
<a href="jobs" class="overview-stat">
|
||||
<span class="overview-stat-label">Parts required</span>
|
||||
<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>
|
||||
|
||||
<hr class="my-4" />
|
||||
<section class="overview-grid">
|
||||
<article class="overview-panel overview-recent-jobs">
|
||||
<div class="overview-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Work queue</p>
|
||||
<h2>Recently created jobs</h2>
|
||||
</div>
|
||||
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
@if (overview.RecentJobs.Count == 0)
|
||||
{
|
||||
<div class="overview-empty">
|
||||
<i class="bi bi-layers" aria-hidden="true"></i>
|
||||
<p>No jobs yet. Create a job to begin building your planning history.</p>
|
||||
<a href="jobs/new" class="btn btn-primary">Create first job</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overview-job-list">
|
||||
@foreach (var job in overview.RecentJobs)
|
||||
{
|
||||
<a href="jobs/@job.Id" class="overview-job-row">
|
||||
<span class="overview-job-id">@job.JobNumber</span>
|
||||
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
|
||||
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
|
||||
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
|
||||
@if (job.IsLocked)
|
||||
{
|
||||
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<h4>How It Works</h4>
|
||||
<ol>
|
||||
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
|
||||
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li>
|
||||
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
|
||||
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li>
|
||||
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
|
||||
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||
<h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
|
||||
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
|
||||
</div>
|
||||
|
||||
@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>
|
||||
<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.
|
||||
@@ -229,17 +229,40 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Import Stock Modal *@
|
||||
@if (showImportModal)
|
||||
@* Add/Edit Stock Modal *@
|
||||
@if (showStockModal)
|
||||
{
|
||||
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import Stock from Inventory</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
|
||||
<h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (editingStock == null)
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
|
||||
type="button" disabled="@(job.Parts.Count == 0)"
|
||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
|
||||
@onclick="() => stockModalTab = StockModalTab.Inventory">
|
||||
From Inventory
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
|
||||
type="button"
|
||||
@onclick="() => stockModalTab = StockModalTab.Custom">
|
||||
Custom Length
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||
{
|
||||
@if (loadingImport)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
@@ -276,7 +299,6 @@ else
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th>Length</th>
|
||||
<th>On Hand</th>
|
||||
<th style="width: 120px;">Qty to Use</th>
|
||||
<th style="width: 100px;">Priority</th>
|
||||
</tr>
|
||||
@@ -289,7 +311,6 @@ else
|
||||
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
|
||||
</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
|
||||
<td>@candidate.StockItem.QuantityOnHand</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
|
||||
min="-1" disabled="@(!candidate.Selected)" />
|
||||
@@ -309,9 +330,109 @@ else
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
||||
}
|
||||
}
|
||||
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
|
||||
disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Stock Length</label>
|
||||
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var stock in availableStockItems)
|
||||
{
|
||||
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Qty to Use</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">-1 = unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Length</label>
|
||||
<LengthInput @bind-Value="newStock.LengthInches" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">Use -1 for unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
|
||||
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||
{
|
||||
@if (importCandidates.Count > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
||||
@@ -319,6 +440,17 @@ else
|
||||
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
||||
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,18 +484,16 @@ else
|
||||
private int partSelectedMaterialId;
|
||||
private List<PartRow> partRows = new();
|
||||
|
||||
// Stock form
|
||||
private bool showStockForm;
|
||||
private bool showCustomStockForm;
|
||||
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
|
||||
private enum StockModalTab { Inventory, Custom }
|
||||
private bool showStockModal;
|
||||
private StockModalTab stockModalTab = StockModalTab.Inventory;
|
||||
private JobStock newStock = new();
|
||||
private JobStock? editingStock;
|
||||
private string? stockErrorMessage;
|
||||
private MaterialShape? stockSelectedShape;
|
||||
private int stockSelectedMaterialId;
|
||||
private List<StockItem> availableStockItems = new();
|
||||
|
||||
// Import modal
|
||||
private bool showImportModal;
|
||||
private bool loadingImport;
|
||||
private List<ImportStockCandidate> importCandidates = new();
|
||||
private string? importErrorMessage;
|
||||
@@ -373,6 +503,7 @@ else
|
||||
private MultiMaterialPackingSummary? summary;
|
||||
private bool optimizing;
|
||||
private bool lockingJob;
|
||||
private bool showLengthsInInches = true;
|
||||
|
||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||
@@ -381,6 +512,9 @@ else
|
||||
|
||||
private bool IsNew => !Id.HasValue;
|
||||
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()
|
||||
{
|
||||
@@ -556,8 +690,10 @@ else
|
||||
<td>
|
||||
@if (!job.IsLocked)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<div class="table-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -743,31 +879,16 @@ else
|
||||
<h5 class="mb-0">Stock for This Job</h5>
|
||||
@if (!job.IsLocked)
|
||||
{
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
|
||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
||||
Import from Inventory
|
||||
</button>
|
||||
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showStockForm)
|
||||
{
|
||||
@RenderStockFromInventoryForm()
|
||||
}
|
||||
else if (showCustomStockForm)
|
||||
{
|
||||
@RenderCustomStockForm()
|
||||
}
|
||||
|
||||
@if (job.Stock.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-2">No stock configured for this job.</p>
|
||||
<p class="small">Add stock from your inventory or define custom lengths.</p>
|
||||
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
|
||||
<p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -778,122 +899,6 @@ else
|
||||
</div>
|
||||
};
|
||||
|
||||
private RenderFragment RenderStockFromInventoryForm() => __builder =>
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
|
||||
disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Stock Length</label>
|
||||
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var stock in availableStockItems)
|
||||
{
|
||||
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@stock.QuantityOnHand available)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Qty to Use</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">
|
||||
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private RenderFragment RenderCustomStockForm() => __builder =>
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Length</label>
|
||||
<LengthInput @bind-Value="newStock.LengthInches" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
|
||||
<small class="text-muted">Use -1 for unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
||||
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private RenderFragment RenderStockTable() => __builder =>
|
||||
{
|
||||
<div class="table-responsive">
|
||||
@@ -929,8 +934,10 @@ else
|
||||
<td>
|
||||
@if (!job.IsLocked)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<div class="table-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -984,6 +991,20 @@ else
|
||||
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
|
||||
<i class="bi bi-printer me-1"></i> Print Report
|
||||
</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)
|
||||
{
|
||||
@@ -1024,7 +1045,7 @@ else
|
||||
<div class="col-md-3 col-6 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
|
||||
<h2 class="card-title mb-0">@FormatResultLength(summary.TotalWaste)</h2>
|
||||
<p class="card-text text-muted">Total Waste</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1039,29 +1060,27 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase List -->
|
||||
<div class="card mb-4 print-purchase-list">
|
||||
@if (job.CuttingTool != null)
|
||||
{
|
||||
<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">
|
||||
<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)
|
||||
{
|
||||
<span class="badge bg-success"><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>
|
||||
<span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@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
|
||||
{
|
||||
@@ -1081,7 +1100,7 @@ else
|
||||
{
|
||||
<tr>
|
||||
<td>@materialResult.Material.DisplayName</td>
|
||||
<td>@ArchUnits.FormatFromInches(group.Key)</td>
|
||||
<td>@FormatResultLength(group.Key)</td>
|
||||
<td class="text-end">@group.Count()</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -1089,7 +1108,7 @@ else
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="fw-bold">
|
||||
<td colspan="2">Total</td>
|
||||
<td colspan="2">Total Required</td>
|
||||
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1125,7 +1144,7 @@ else
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> —
|
||||
No stock lengths available or parts too long.
|
||||
not enough stock quantity entered for this job, no stock lengths configured, or parts too long.
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1155,16 +1174,21 @@ else
|
||||
{
|
||||
<tr>
|
||||
<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>
|
||||
@foreach (var item in entry.Bin.Items)
|
||||
{
|
||||
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;">
|
||||
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
||||
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
|
||||
@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>
|
||||
}
|
||||
</td>
|
||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
|
||||
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
|
||||
</tr>
|
||||
binNum++;
|
||||
}
|
||||
@@ -1228,21 +1252,33 @@ else
|
||||
await JS.InvokeVoidAsync("printWithTitle", filename);
|
||||
}
|
||||
|
||||
private void ShowAddCustomStock()
|
||||
private async Task ShowAddStockModal()
|
||||
{
|
||||
editingStock = null;
|
||||
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
|
||||
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
|
||||
stockSelectedShape = null;
|
||||
showStockForm = false;
|
||||
showCustomStockForm = true;
|
||||
stockSelectedMaterialId = 0;
|
||||
availableStockItems.Clear();
|
||||
stockErrorMessage = null;
|
||||
importErrorMessage = null;
|
||||
importCandidates.Clear();
|
||||
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
|
||||
showStockModal = true;
|
||||
|
||||
if (job.Parts.Count > 0)
|
||||
{
|
||||
await LoadImportCandidatesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelStockForm()
|
||||
private void CloseStockModal()
|
||||
{
|
||||
showStockForm = false;
|
||||
showCustomStockForm = false;
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
newStock = new();
|
||||
importCandidates.Clear();
|
||||
stockErrorMessage = null;
|
||||
importErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task OnStockShapeChanged()
|
||||
@@ -1257,17 +1293,10 @@ else
|
||||
{
|
||||
newStock.MaterialId = stockSelectedMaterialId;
|
||||
newStock.StockItemId = null;
|
||||
if (stockSelectedMaterialId > 0)
|
||||
{
|
||||
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
|
||||
}
|
||||
else
|
||||
{
|
||||
availableStockItems.Clear();
|
||||
}
|
||||
await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
|
||||
}
|
||||
|
||||
private void EditStock(JobStock stock)
|
||||
private async Task EditStock(JobStock stock)
|
||||
{
|
||||
editingStock = stock;
|
||||
newStock = new JobStock
|
||||
@@ -1285,18 +1314,23 @@ else
|
||||
stockSelectedShape = stock.Material?.Shape;
|
||||
stockSelectedMaterialId = stock.MaterialId;
|
||||
stockErrorMessage = null;
|
||||
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
|
||||
showStockModal = true;
|
||||
|
||||
if (stock.IsCustomLength)
|
||||
if (!stock.IsCustomLength)
|
||||
{
|
||||
showStockForm = false;
|
||||
showCustomStockForm = true;
|
||||
// Load the candidate stock lengths without going through OnStockMaterialChanged,
|
||||
// which would reset newStock.StockItemId and blank the prefilled selection.
|
||||
// Awaited (not fire-and-forget) so Blazor re-renders once the list arrives.
|
||||
await LoadAvailableStockItemsAsync(stock.MaterialId);
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
private async Task LoadAvailableStockItemsAsync(int materialId)
|
||||
{
|
||||
showStockForm = true;
|
||||
showCustomStockForm = false;
|
||||
_ = OnStockMaterialChanged();
|
||||
}
|
||||
availableStockItems = materialId > 0
|
||||
? await JobService.GetAvailableStockForMaterialAsync(materialId)
|
||||
: new List<StockItem>();
|
||||
}
|
||||
|
||||
private async Task SaveStockFromInventoryAsync()
|
||||
@@ -1321,9 +1355,9 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStock.Quantity < 1)
|
||||
if (newStock.Quantity < -1 || newStock.Quantity == 0)
|
||||
{
|
||||
stockErrorMessage = "Quantity must be at least 1";
|
||||
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1348,7 +1382,7 @@ else
|
||||
}
|
||||
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockForm = false;
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
@@ -1395,7 +1429,7 @@ else
|
||||
}
|
||||
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showCustomStockForm = false;
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
@@ -1409,13 +1443,12 @@ else
|
||||
summary = null;
|
||||
}
|
||||
|
||||
// Import modal methods
|
||||
private async Task ShowImportModal()
|
||||
// Loads inventory stock candidates for the modal's "From Inventory" tab
|
||||
private async Task LoadImportCandidatesAsync()
|
||||
{
|
||||
importErrorMessage = null;
|
||||
importCandidates.Clear();
|
||||
loadingImport = true;
|
||||
showImportModal = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1450,13 +1483,6 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseImportModal()
|
||||
{
|
||||
showImportModal = false;
|
||||
importCandidates.Clear();
|
||||
importErrorMessage = null;
|
||||
}
|
||||
|
||||
private void ToggleAllImportCandidates(bool selected)
|
||||
{
|
||||
foreach (var c in importCandidates)
|
||||
@@ -1491,7 +1517,7 @@ else
|
||||
}
|
||||
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showImportModal = false;
|
||||
showStockModal = false;
|
||||
importCandidates.Clear();
|
||||
packResult = null;
|
||||
summary = null;
|
||||
|
||||
@@ -62,9 +62,11 @@ else
|
||||
<td>@(job.CuttingTool?.Name ?? "-")</td>
|
||||
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ else
|
||||
<td>@material.Grade</td>
|
||||
<td>@material.Size</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>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -70,94 +70,6 @@ else
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!IsNew)
|
||||
{
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
Inventory
|
||||
<span class="badge @(stockItem.QuantityOnHand > 0 ? "bg-success" : "bg-secondary") ms-2">@stockItem.QuantityOnHand on hand</span>
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowStockForm">Add/Adjust Stock</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showStockForm)
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>Stock Transaction</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" @bind="stockTransactionType">
|
||||
<option value="add">Receive Stock</option>
|
||||
<option value="adjust">Set Quantity</option>
|
||||
<option value="scrap">Scrap/Waste</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">@(stockTransactionType == "adjust" ? "New Quantity" : "Quantity")</label>
|
||||
<input type="number" class="form-control" @bind="stockQuantity" min="0" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Notes</label>
|
||||
<InputText class="form-control" @bind-Value="stockNotes" />
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockFormErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-2 mb-0">@stockFormErrorMessage</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="SaveStockTransactionAsync" disabled="@savingStockTransaction">
|
||||
@if (savingStockTransaction)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelStockForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (transactions.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transaction history yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var txn in transactions)
|
||||
{
|
||||
<tr>
|
||||
<td>@txn.CreatedAt.ToLocalTime().ToString("MM/dd/yy HH:mm")</td>
|
||||
<td>
|
||||
<span class="badge @GetTransactionBadgeClass(txn.Type)">@txn.Type</span>
|
||||
</td>
|
||||
<td class="@(txn.Quantity >= 0 ? "text-success" : "text-danger")">
|
||||
@(txn.Quantity >= 0 ? "+" : "")@txn.Quantity
|
||||
</td>
|
||||
<td>@(txn.Notes ?? "-")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -167,19 +79,10 @@ else
|
||||
|
||||
private StockItem stockItem = new();
|
||||
private List<Material> materials = new();
|
||||
private List<StockTransaction> transactions = new();
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private string? errorMessage;
|
||||
|
||||
// Stock transaction form
|
||||
private bool showStockForm;
|
||||
private bool savingStockTransaction;
|
||||
private string stockTransactionType = "add";
|
||||
private int stockQuantity;
|
||||
private string? stockNotes;
|
||||
private string? stockFormErrorMessage;
|
||||
|
||||
private bool IsNew => !Id.HasValue;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -195,83 +98,10 @@ else
|
||||
return;
|
||||
}
|
||||
stockItem = existing;
|
||||
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private string GetTransactionBadgeClass(StockTransactionType type) => type switch
|
||||
{
|
||||
StockTransactionType.Received => "bg-success",
|
||||
StockTransactionType.Used => "bg-primary",
|
||||
StockTransactionType.Adjustment => "bg-warning text-dark",
|
||||
StockTransactionType.Scrapped => "bg-danger",
|
||||
StockTransactionType.Returned => "bg-info",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private void ShowStockForm()
|
||||
{
|
||||
stockTransactionType = "add";
|
||||
stockQuantity = 0;
|
||||
stockNotes = null;
|
||||
stockFormErrorMessage = null;
|
||||
showStockForm = true;
|
||||
}
|
||||
|
||||
private void CancelStockForm()
|
||||
{
|
||||
showStockForm = false;
|
||||
stockFormErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task SaveStockTransactionAsync()
|
||||
{
|
||||
stockFormErrorMessage = null;
|
||||
savingStockTransaction = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (stockQuantity <= 0 && stockTransactionType != "adjust")
|
||||
{
|
||||
stockFormErrorMessage = "Quantity must be greater than zero";
|
||||
return;
|
||||
}
|
||||
|
||||
if (stockTransactionType == "adjust" && stockQuantity < 0)
|
||||
{
|
||||
stockFormErrorMessage = "Quantity cannot be negative";
|
||||
return;
|
||||
}
|
||||
|
||||
switch (stockTransactionType)
|
||||
{
|
||||
case "add":
|
||||
await StockItemService.AddStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
case "adjust":
|
||||
await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
case "scrap":
|
||||
await StockItemService.ScrapStockAsync(Id!.Value, stockQuantity, stockNotes);
|
||||
break;
|
||||
}
|
||||
|
||||
// Refresh
|
||||
var updated = await StockItemService.GetByIdAsync(Id!.Value);
|
||||
if (updated != null)
|
||||
{
|
||||
stockItem = updated;
|
||||
}
|
||||
transactions = await StockItemService.GetTransactionHistoryAsync(Id!.Value, 20);
|
||||
showStockForm = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingStockTransaction = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveStockItemAsync()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Stock items represent the specific lengths of material you have available for cutting. Each stock item links
|
||||
a material to a length and tracks how many pieces you have on hand.
|
||||
Stock items represent the specific lengths of material you can cut from. Add the lengths you typically
|
||||
work with here, then pick from them when adding stock to a job.
|
||||
</p>
|
||||
|
||||
@if (loading)
|
||||
@@ -46,7 +46,6 @@ else
|
||||
<th>Grade</th>
|
||||
<th>Size</th>
|
||||
<th>Length</th>
|
||||
<th>On Hand</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -60,17 +59,7 @@ else
|
||||
<td>@item.Material.Size</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
|
||||
<td>
|
||||
@if (item.QuantityOnHand > 0)
|
||||
{
|
||||
<span class="badge bg-success">@item.QuantityOnHand</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">0</span>
|
||||
}
|
||||
</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>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,10 @@ else
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -310,7 +310,6 @@ public class JobsController : ControllerBase
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
IsActive = s.IsActive
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ public class StockItemsController : ControllerBase
|
||||
MaterialId = dto.MaterialId,
|
||||
LengthInches = (decimal)lengthInches,
|
||||
Name = dto.Name,
|
||||
QuantityOnHand = dto.QuantityOnHand,
|
||||
Notes = dto.Notes
|
||||
};
|
||||
|
||||
@@ -118,87 +117,6 @@ public class StockItemsController : ControllerBase
|
||||
return Ok(items.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{id}/transactions")]
|
||||
public async Task<ActionResult<List<StockTransactionDto>>> GetTransactions(int id, [FromQuery] int? limit = null)
|
||||
{
|
||||
var item = await _stockItemService.GetByIdAsync(id);
|
||||
if (item == null)
|
||||
return NotFound();
|
||||
|
||||
var transactions = await _stockItemService.GetTransactionHistoryAsync(id, limit);
|
||||
return Ok(transactions.Select(MapTransactionToDto).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id}/receive")]
|
||||
public async Task<ActionResult<StockTransactionDto>> ReceiveStock(int id, AddStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/use")]
|
||||
public async Task<ActionResult<StockTransactionDto>> UseStock(int id, UseStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.UseStockAsync(id, dto.Quantity, dto.JobId, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/adjust")]
|
||||
public async Task<ActionResult<StockTransactionDto>> AdjustStock(int id, AdjustStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.AdjustStockAsync(id, dto.NewQuantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/scrap")]
|
||||
public async Task<ActionResult<StockTransactionDto>> ScrapStock(int id, ScrapStockDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = await _stockItemService.ScrapStockAsync(id, dto.Quantity, dto.Notes);
|
||||
return Ok(MapTransactionToDto(transaction));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/recalculate")]
|
||||
public async Task<ActionResult<object>> RecalculateStock(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newQuantity = await _stockItemService.RecalculateQuantityAsync(id);
|
||||
return Ok(new { QuantityOnHand = newQuantity });
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
private static StockItemDto MapToDto(StockItem s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
@@ -207,20 +125,7 @@ public class StockItemsController : ControllerBase
|
||||
LengthInches = s.LengthInches,
|
||||
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive
|
||||
};
|
||||
|
||||
private static StockTransactionDto MapTransactionToDto(StockTransaction t) => new()
|
||||
{
|
||||
Id = t.Id,
|
||||
StockItemId = t.StockItemId,
|
||||
Quantity = t.Quantity,
|
||||
Type = t.Type.ToString(),
|
||||
JobId = t.JobId,
|
||||
JobNumber = t.Job?.JobNumber,
|
||||
Notes = t.Notes,
|
||||
CreatedAt = t.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ public class CatalogStockItemDto
|
||||
{
|
||||
public decimal LengthInches { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ public class StockItemDto
|
||||
public decimal LengthInches { get; set; }
|
||||
public string LengthFormatted { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -18,7 +17,6 @@ public class CreateStockItemDto
|
||||
public int MaterialId { get; set; }
|
||||
public string Length { get; set; } = string.Empty;
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -28,40 +26,3 @@ public class UpdateStockItemDto
|
||||
public string? Name { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class StockTransactionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int StockItemId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public int? JobId { get; set; }
|
||||
public string? JobNumber { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class AddStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UseStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public int? JobId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class AdjustStockDto
|
||||
{
|
||||
public int NewQuantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class ScrapStockDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ public class ApplicationDbContext : DbContext
|
||||
public DbSet<Material> Materials => Set<Material>();
|
||||
public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>();
|
||||
public DbSet<StockItem> StockItems => Set<StockItem>();
|
||||
public DbSet<StockTransaction> StockTransactions => Set<StockTransaction>();
|
||||
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
|
||||
public DbSet<Job> Jobs => Set<Job>();
|
||||
public DbSet<JobPart> JobParts => Set<JobPart>();
|
||||
@@ -157,24 +156,6 @@ public class ApplicationDbContext : DbContext
|
||||
entity.HasIndex(e => new { e.MaterialId, e.LengthInches }).IsUnique();
|
||||
});
|
||||
|
||||
// StockTransaction
|
||||
modelBuilder.Entity<StockTransaction>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
entity.HasOne(e => e.StockItem)
|
||||
.WithMany(s => s.Transactions)
|
||||
.HasForeignKey(e => e.StockItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Job)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// CuttingTool
|
||||
modelBuilder.Entity<CuttingTool>(entity =>
|
||||
{
|
||||
|
||||
@@ -6,12 +6,10 @@ public class StockItem
|
||||
public int MaterialId { get; set; }
|
||||
public decimal LengthInches { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int QuantityOnHand { get; set; } = 0;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public Material Material { get; set; } = null!;
|
||||
public ICollection<StockTransaction> Transactions { get; set; } = new List<StockTransaction>();
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace CutList.Web.Data.Entities;
|
||||
|
||||
public class StockTransaction
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int StockItemId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public StockTransactionType Type { get; set; }
|
||||
public int? JobId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public StockItem StockItem { get; set; } = null!;
|
||||
public Job? Job { get; set; }
|
||||
}
|
||||
|
||||
public enum StockTransactionType
|
||||
{
|
||||
Received,
|
||||
Used,
|
||||
Adjustment,
|
||||
Scrapped,
|
||||
Returned
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CutList.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CutList.Web.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260801145943_RemoveInventoryQuantityTracking")]
|
||||
partial class RemoveInventoryQuantityTracking
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.HasSequence("MaterialDimensionsSequence");
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal>("KerfInches")
|
||||
.HasPrecision(6, 4)
|
||||
.HasColumnType("decimal(6,4)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CuttingTools");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
IsActive = true,
|
||||
IsDefault = true,
|
||||
KerfInches = 0.0625m,
|
||||
Name = "Bandsaw"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2,
|
||||
IsActive = true,
|
||||
IsDefault = false,
|
||||
KerfInches = 0.125m,
|
||||
Name = "Chop Saw"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3,
|
||||
IsActive = true,
|
||||
IsDefault = false,
|
||||
KerfInches = 0.0625m,
|
||||
Name = "Cold Cut Saw"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4,
|
||||
IsActive = true,
|
||||
IsDefault = false,
|
||||
KerfInches = 0.0625m,
|
||||
Name = "Hacksaw"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<string>("Customer")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int?>("CuttingToolId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("JobNumber")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTime?>("LockedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("OptimizationResultJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("OptimizedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CuttingToolId");
|
||||
|
||||
b.HasIndex("JobNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("JobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("LengthInches")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<int>("MaterialId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("MaterialId");
|
||||
|
||||
b.ToTable("JobParts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsCustomLength")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("JobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("LengthInches")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<int>("MaterialId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("StockItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("MaterialId");
|
||||
|
||||
b.HasIndex("StockItemId");
|
||||
|
||||
b.ToTable("JobStocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<string>("Grade")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Shape")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Size")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Materials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValueSql("NEXT VALUE FOR [MaterialDimensionsSequence]");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseSequence(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("MaterialId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaterialId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable((string)null);
|
||||
|
||||
b.UseTpcMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal>("LengthInches")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<int>("MaterialId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaterialId", "LengthInches")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StockItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Leg1")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Leg2")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Thickness")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Leg1");
|
||||
|
||||
b.ToTable("DimAngle", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Flange")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Height")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Web")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Height");
|
||||
|
||||
b.ToTable("DimChannel", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Thickness")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Width")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Width");
|
||||
|
||||
b.ToTable("DimFlatBar", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Height")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("WeightPerFoot")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Height");
|
||||
|
||||
b.ToTable("DimIBeam", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("NominalSize")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<string>("Schedule")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<decimal?>("Wall")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("NominalSize");
|
||||
|
||||
b.ToTable("DimPipe", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Height")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Wall")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Width")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Width");
|
||||
|
||||
b.ToTable("DimRectangularTube", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Diameter")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Diameter");
|
||||
|
||||
b.ToTable("DimRoundBar", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("OuterDiameter")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Wall")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("OuterDiameter");
|
||||
|
||||
b.ToTable("DimRoundTube", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Size")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Size");
|
||||
|
||||
b.ToTable("DimSquareBar", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
|
||||
b.Property<decimal>("Size")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.Property<decimal>("Wall")
|
||||
.HasPrecision(10, 4)
|
||||
.HasColumnType("decimal(10,4)");
|
||||
|
||||
b.HasIndex("Size");
|
||||
|
||||
b.ToTable("DimSquareTube", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
|
||||
.WithMany("Jobs")
|
||||
.HasForeignKey("CuttingToolId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("CuttingTool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
|
||||
.WithMany("Parts")
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
|
||||
.WithMany("JobParts")
|
||||
.HasForeignKey("MaterialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Job");
|
||||
|
||||
b.Navigation("Material");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
|
||||
.WithMany("Stock")
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
|
||||
.WithMany()
|
||||
.HasForeignKey("MaterialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("StockItemId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Job");
|
||||
|
||||
b.Navigation("Material");
|
||||
|
||||
b.Navigation("StockItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
|
||||
.WithOne("Dimensions")
|
||||
.HasForeignKey("CutList.Web.Data.Entities.MaterialDimensions", "MaterialId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Material");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
|
||||
.WithMany("StockItems")
|
||||
.HasForeignKey("MaterialId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Material");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
|
||||
{
|
||||
b.Navigation("Jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
|
||||
{
|
||||
b.Navigation("Parts");
|
||||
|
||||
b.Navigation("Stock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
|
||||
{
|
||||
b.Navigation("Dimensions");
|
||||
|
||||
b.Navigation("JobParts");
|
||||
|
||||
b.Navigation("StockItems");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CutList.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveInventoryQuantityTracking : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StockTransactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuantityOnHand",
|
||||
table: "StockItems");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "QuantityOnHand",
|
||||
table: "StockItems",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StockTransactions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
JobId = table.Column<int>(type: "int", nullable: true),
|
||||
StockItemId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||
Type = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StockTransactions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StockTransactions_Jobs_JobId",
|
||||
column: x => x.JobId,
|
||||
principalTable: "Jobs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_StockTransactions_StockItems_StockItemId",
|
||||
column: x => x.StockItemId,
|
||||
principalTable: "StockItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockTransactions_JobId",
|
||||
table: "StockTransactions",
|
||||
column: "JobId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StockTransactions_StockItemId",
|
||||
table: "StockTransactions",
|
||||
column: "StockItemId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-%';
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,9 +325,6 @@ namespace CutList.Web.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("nvarchar(255)");
|
||||
|
||||
b.Property<int>("QuantityOnHand")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -339,44 +336,6 @@ namespace CutList.Web.Migrations
|
||||
b.ToTable("StockItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("GETUTCDATE()");
|
||||
|
||||
b.Property<int?>("JobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("StockItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("StockItemId");
|
||||
|
||||
b.ToTable("StockTransactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
|
||||
{
|
||||
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
|
||||
@@ -632,24 +591,6 @@ namespace CutList.Web.Migrations
|
||||
b.Navigation("Material");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
|
||||
{
|
||||
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("StockItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Job");
|
||||
|
||||
b.Navigation("StockItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
|
||||
{
|
||||
b.Navigation("Jobs");
|
||||
@@ -670,11 +611,6 @@ namespace CutList.Web.Migrations
|
||||
|
||||
b.Navigation("StockItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
|
||||
builder.Services.AddScoped<MaterialService>();
|
||||
builder.Services.AddScoped<StockItemService>();
|
||||
builder.Services.AddScoped<JobService>();
|
||||
builder.Services.AddScoped<OverviewService>();
|
||||
builder.Services.AddScoped<CutListPackingService>();
|
||||
builder.Services.AddScoped<ReportService>();
|
||||
builder.Services.AddScoped<CatalogService>();
|
||||
|
||||
@@ -362,7 +362,6 @@ public class CatalogService
|
||||
MaterialId = material.Id,
|
||||
LengthInches = dto.LengthInches,
|
||||
Name = dto.Name,
|
||||
QuantityOnHand = dto.QuantityOnHand,
|
||||
Notes = dto.Notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -388,7 +387,6 @@ public class CatalogService
|
||||
{
|
||||
LengthInches = s.LengthInches,
|
||||
Name = s.Name,
|
||||
QuantityOnHand = s.QuantityOnHand,
|
||||
Notes = s.Notes
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ public class CutListPackingService
|
||||
// Build stock bins
|
||||
var stockBins = new List<StockBinSource>();
|
||||
|
||||
// Check if job has specific stock configured for this material
|
||||
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0)
|
||||
// Use job-specific stock configuration. Jobs must have stock explicitly configured -
|
||||
// there is no fallback to "whatever's in inventory."
|
||||
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock))
|
||||
{
|
||||
// Use job-specific stock configuration
|
||||
foreach (var stock in materialJobStock.OrderBy(s => s.Priority))
|
||||
{
|
||||
stockBins.Add(new StockBinSource
|
||||
@@ -61,37 +61,6 @@ public class CutListPackingService
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No job-specific stock - use all available stock items for this material
|
||||
var stockItems = await context.StockItems
|
||||
.Where(s => s.MaterialId == materialId && s.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var stock in stockItems)
|
||||
{
|
||||
if (stock.QuantityOnHand > 0)
|
||||
{
|
||||
// In-stock with finite quantity
|
||||
stockBins.Add(new StockBinSource
|
||||
{
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = stock.QuantityOnHand,
|
||||
Priority = 1,
|
||||
IsInStock = true
|
||||
});
|
||||
}
|
||||
|
||||
// Always add as purchasable (unlimited) - algorithm will use in-stock first due to priority
|
||||
stockBins.Add(new StockBinSource
|
||||
{
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = -1, // unlimited
|
||||
Priority = 2,
|
||||
IsInStock = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (stockBins.Count == 0)
|
||||
{
|
||||
@@ -139,7 +108,14 @@ public class CutListPackingService
|
||||
var inStockBins = new List<Bin>();
|
||||
var toBePurchasedBins = new List<Bin>();
|
||||
|
||||
// Track remaining in-stock quantities from the stock bins we configured
|
||||
// Catalog-sourced stock rows with an unlimited (-1) quantity are always in-stock,
|
||||
// regardless of how many bins of that length get packed.
|
||||
var unlimitedInStockLengths = stockBins
|
||||
.Where(s => s.IsInStock && s.Quantity == -1)
|
||||
.Select(s => s.LengthInches)
|
||||
.ToHashSet();
|
||||
|
||||
// Track remaining in-stock quantities from the finite-quantity catalog stock bins we configured
|
||||
var remainingStock = stockBins
|
||||
.Where(s => s.IsInStock && s.Quantity > 0)
|
||||
.GroupBy(s => s.LengthInches)
|
||||
@@ -149,9 +125,14 @@ public class CutListPackingService
|
||||
{
|
||||
var binLength = (decimal)bin.Length;
|
||||
|
||||
// Check if this can come from in-stock
|
||||
if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
|
||||
if (unlimitedInStockLengths.Contains(binLength))
|
||||
{
|
||||
// Unlimited catalog-sourced stock - always in-stock
|
||||
inStockBins.Add(bin);
|
||||
}
|
||||
else if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
|
||||
{
|
||||
// Check if this can come from in-stock
|
||||
inStockBins.Add(bin);
|
||||
remainingStock[binLength] = remaining - 1;
|
||||
}
|
||||
|
||||
@@ -42,35 +42,17 @@ public class JobService
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
job ??= new Job();
|
||||
job.JobNumber = await GenerateJobNumberAsync(context);
|
||||
job.JobNumber = CreateTemporaryJobNumber();
|
||||
job.CreatedAt = DateTime.UtcNow;
|
||||
context.Jobs.Add(job);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
job.JobNumber = $"JOB-{job.Id}";
|
||||
await context.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateJobNumberAsync()
|
||||
{
|
||||
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}";
|
||||
}
|
||||
private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
public async Task<Job> QuickCreateAsync(string? customer = null)
|
||||
{
|
||||
@@ -142,7 +124,7 @@ public class JobService
|
||||
|
||||
var duplicate = new Job
|
||||
{
|
||||
JobNumber = await GenerateJobNumberAsync(context),
|
||||
JobNumber = CreateTemporaryJobNumber(),
|
||||
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
||||
Customer = original.Customer,
|
||||
CuttingToolId = original.CuttingToolId,
|
||||
@@ -153,6 +135,9 @@ public class JobService
|
||||
context.Jobs.Add(duplicate);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
duplicate.JobNumber = $"JOB-{duplicate.Id}";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Copy parts
|
||||
foreach (var part in original.Parts)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using CutList.Web.Data;
|
||||
using CutList.Web.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CutList.Web.Services;
|
||||
|
||||
public class OverviewService
|
||||
{
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _factory;
|
||||
|
||||
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<OverviewSnapshot> GetSnapshotAsync()
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var recentJobs = await context.Jobs
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(j => new RecentJobOverview(
|
||||
j.Id,
|
||||
j.JobNumber,
|
||||
j.Name,
|
||||
j.Customer,
|
||||
j.CreatedAt,
|
||||
j.LockedAt != null,
|
||||
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
|
||||
.ToListAsync();
|
||||
|
||||
var frequentStock = await context.JobStocks
|
||||
.AsNoTracking()
|
||||
.GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.MaterialId,
|
||||
g.Key.LengthInches,
|
||||
g.Key.Shape,
|
||||
g.Key.Size,
|
||||
JobCount = g.Select(s => s.JobId).Distinct().Count()
|
||||
})
|
||||
.OrderByDescending(s => s.JobCount)
|
||||
.ThenBy(s => s.Shape)
|
||||
.ThenBy(s => s.Size)
|
||||
.ThenBy(s => s.LengthInches)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
var totalJobs = await context.Jobs.CountAsync();
|
||||
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
|
||||
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
|
||||
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
|
||||
|
||||
return new OverviewSnapshot(
|
||||
totalJobs,
|
||||
openJobs,
|
||||
totalParts,
|
||||
activeStockItems,
|
||||
recentJobs,
|
||||
frequentStock.Select(s => new FrequentStockOverview(
|
||||
s.MaterialId,
|
||||
$"{s.Shape.GetDisplayName()} - {s.Size}",
|
||||
s.LengthInches,
|
||||
s.JobCount)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public record OverviewSnapshot(
|
||||
int TotalJobs,
|
||||
int OpenJobs,
|
||||
int TotalParts,
|
||||
int ActiveStockItems,
|
||||
IReadOnlyList<RecentJobOverview> RecentJobs,
|
||||
IReadOnlyList<FrequentStockOverview> FrequentStock);
|
||||
|
||||
public record RecentJobOverview(
|
||||
int Id,
|
||||
string JobNumber,
|
||||
string? Name,
|
||||
string? Customer,
|
||||
DateTime CreatedAt,
|
||||
bool IsLocked,
|
||||
int PartCount);
|
||||
|
||||
public record FrequentStockOverview(
|
||||
int MaterialId,
|
||||
string MaterialName,
|
||||
decimal LengthInches,
|
||||
int JobCount);
|
||||
@@ -108,147 +108,4 @@ public class StockItemService
|
||||
|
||||
return await query.AnyAsync();
|
||||
}
|
||||
|
||||
// Stock transaction methods
|
||||
public async Task<StockTransaction> AddStockAsync(int stockItemId, int quantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = quantity,
|
||||
Type = StockTransactionType.Received,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand += quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> UseStockAsync(int stockItemId, int quantity, int? jobId = null, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = -quantity,
|
||||
Type = StockTransactionType.Used,
|
||||
JobId = jobId,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand -= quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> AdjustStockAsync(int stockItemId, int newQuantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var difference = newQuantity - stockItem.QuantityOnHand;
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = difference,
|
||||
Type = StockTransactionType.Adjustment,
|
||||
Notes = notes ?? "Manual adjustment",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand = newQuantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<StockTransaction> ScrapStockAsync(int stockItemId, int quantity, string? notes = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var transaction = new StockTransaction
|
||||
{
|
||||
StockItemId = stockItemId,
|
||||
Quantity = -quantity,
|
||||
Type = StockTransactionType.Scrapped,
|
||||
Notes = notes,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
stockItem.QuantityOnHand -= quantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
context.StockTransactions.Add(transaction);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<List<StockTransaction>> GetTransactionHistoryAsync(int stockItemId, int? limit = null)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var query = context.StockTransactions
|
||||
.Include(t => t.Job)
|
||||
.Where(t => t.StockItemId == stockItemId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> RecalculateQuantityAsync(int stockItemId)
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var stockItem = await context.StockItems.FindAsync(stockItemId)
|
||||
?? throw new InvalidOperationException($"Stock item {stockItemId} not found");
|
||||
|
||||
var calculatedQuantity = await context.StockTransactions
|
||||
.Where(t => t.StockItemId == stockItemId)
|
||||
.SumAsync(t => t.Quantity);
|
||||
|
||||
if (stockItem.QuantityOnHand != calculatedQuantity)
|
||||
{
|
||||
stockItem.QuantityOnHand = calculatedQuantity;
|
||||
stockItem.UpdatedAt = DateTime.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return calculatedQuantity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ main {
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f3f1ee;
|
||||
border-bottom: 1px solid #e0ddd8;
|
||||
background-color: #edf2f5;
|
||||
border-bottom: 1px solid #d4dee5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
@@ -145,7 +145,7 @@ main {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
@@ -225,3 +225,95 @@ h1 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
|
||||
:root {
|
||||
--ink: #17212b;
|
||||
--muted: #617282;
|
||||
--paper: #f3f6f8;
|
||||
--paper-bright: #ffffff;
|
||||
--line: #d4dee5;
|
||||
--accent: #2b75a8;
|
||||
--accent-dark: #1d5d8d;
|
||||
--navy: #203747;
|
||||
--rail: #18242e;
|
||||
}
|
||||
|
||||
html, body { background: var(--paper); color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { letter-spacing: -.01em; }
|
||||
main { background: var(--paper); box-shadow: none; min-width: 0; }
|
||||
.sidebar { background: var(--rail); border-right: 1px solid #314653; }
|
||||
.content { margin: 0 auto; max-width: 1440px; padding: 2.75rem clamp(1.5rem, 4vw, 4.5rem) 4rem; }
|
||||
.eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
|
||||
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 650; letter-spacing: -.025em; }
|
||||
h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem); line-height: 1.04; }
|
||||
h2 { font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.12; }
|
||||
.job-title { font-size: clamp(1.5rem, 2.1vw, 2.1rem); line-height: 1.12; }
|
||||
a, .btn-link { color: var(--accent-dark); }
|
||||
.btn { border-radius: .35rem; font-size: .85rem; font-weight: 750; letter-spacing: .01em; padding: .65rem 1rem; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
|
||||
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
|
||||
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.btn-success { background: #267c6c; border-color: #267c6c; }
|
||||
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
|
||||
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
|
||||
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
|
||||
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
|
||||
.card-header { background: #edf2f5; }
|
||||
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
|
||||
.table > :not(caption) > * > * { padding: .85rem .75rem; }
|
||||
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
|
||||
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
|
||||
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
|
||||
.alert { border-radius: .4rem; border-width: 1px; }
|
||||
|
||||
/* Results cut badges keep the part number and its length visually distinct. */
|
||||
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
|
||||
.cut-part-badge-name { padding: .35em .55em; }
|
||||
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
|
||||
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
|
||||
|
||||
/* Overview */
|
||||
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
|
||||
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
|
||||
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
|
||||
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
|
||||
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
|
||||
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
|
||||
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
|
||||
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
|
||||
.overview-recent-jobs { min-width: 0; }
|
||||
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
|
||||
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
|
||||
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
|
||||
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||
.dashboard-hero .eyebrow { color: #a8c5d7; }
|
||||
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
|
||||
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
|
||||
.btn-quiet:hover { color: #8fc5e4; }
|
||||
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
|
||||
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
|
||||
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
|
||||
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
|
||||
.dashboard-section { margin-top: 4rem; }
|
||||
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
|
||||
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
|
||||
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
|
||||
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
|
||||
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
|
||||
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
|
||||
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
|
||||
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
|
||||
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
|
||||
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
|
||||
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
|
||||
|
||||
@@ -141,14 +141,27 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: economy;
|
||||
print-color-adjust: economy;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.print-screen-only,
|
||||
.sidebar,
|
||||
.top-row,
|
||||
.page > main > .top-row,
|
||||
@@ -304,8 +317,16 @@
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep purchase list with cut lists to save paper */
|
||||
.print-purchase-list {
|
||||
.print-cut-method {
|
||||
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;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
@@ -329,6 +350,15 @@
|
||||
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 */
|
||||
.cutlist-material-screen-header {
|
||||
display: none !important;
|
||||
@@ -365,6 +395,12 @@
|
||||
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 */
|
||||
.mb-4 {
|
||||
margin-bottom: 0.5rem !important;
|
||||
|
||||
+14
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,819 @@
|
||||
# Unified Add/Edit Stock Modal Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the Job Edit page's Stock tab three inconsistent add/edit UI surfaces (bulk import modal, inline custom-length form, inline edit forms) with a single "Add Stock" button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
|
||||
|
||||
**Architecture:** Single-file Razor component change in `CutList.Web/Components/Pages/Jobs/Edit.razor`. Three boolean visibility flags (`showStockForm`, `showCustomStockForm`, `showImportModal`) collapse into one (`showStockModal`) plus a `StockModalTab` enum (`Inventory`/`Custom`) for tab selection; the existing `editingStock` field (already present) continues to discriminate Add vs Edit mode. All markup for the three old surfaces is merged into one top-level modal block, following the same top-level-markup-block pattern already used by the existing "Part Modal Dialog" and (soon-to-be-replaced) "Import Stock Modal" blocks in this file.
|
||||
|
||||
**Tech Stack:** .NET 8 Blazor Server, Bootstrap 5 modal/tab markup. No automated test project exists for `CutList.Web` (confirmed: no `*.Tests.csproj` in the solution). Verification is `dotnet build` for compile correctness plus manual browser verification via the `webapp-testing` skill (Playwright) for behavior — there is no unit-test step in this plan.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Full design spec: `docs/superpowers/specs/2026-08-01-unified-add-stock-modal-design.md` — every requirement in it must be reflected below.
|
||||
- No changes to the Parts tab, its modal, `JobService`, or any API surface (spec's "Out of Scope" section).
|
||||
- No change to validation rules or business logic — UI consolidation only. All four existing handler methods (`SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, and the renamed candidate-loading method) keep their existing validation logic verbatim.
|
||||
- Match existing code style in this file: top-level markup blocks (not extra `RenderFragment` C# methods) for modals, mirroring the existing "Part Modal Dialog" block's inline `@if`/`else` branching.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Consolidate Stock tab add/edit UI into one modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `CutList.Web/Components/Pages/Jobs/Edit.razor`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `private enum StockModalTab { Inventory, Custom }`, `private bool showStockModal`, `private StockModalTab stockModalTab`, `private async Task ShowAddStockModal()`, `private void CloseStockModal()`, `private async Task LoadImportCandidatesAsync()` — these are the only new/renamed symbols; every other field/method referenced below (`newStock`, `editingStock`, `stockErrorMessage`, `stockSelectedShape`, `stockSelectedMaterialId`, `availableStockItems`, `loadingImport`, `importCandidates`, `importErrorMessage`, `ImportStockCandidate`, `job`, `DistinctShapes`, `materials`, `ArchUnits`, `OnStockShapeChanged`, `OnStockMaterialChanged`, `SaveStockFromInventoryAsync`, `SaveCustomStockAsync`, `ImportSelectedStockAsync`, `ToggleAllImportCandidates`, `EditStock`, `DeleteStock`, `RenderStockTable`) already exists in the file and keeps its current signature.
|
||||
|
||||
This is one cohesive task — the markup and the code-behind are too interdependent in a single `.razor` file to compile in a valid intermediate state, so it's implemented as one connected set of edits followed by a single build/verification gate.
|
||||
|
||||
- [ ] **Step 1: Replace the Stock-form and Import-modal state fields with the unified modal state**
|
||||
|
||||
In the `@code` block, find this region (currently around line 353-367):
|
||||
|
||||
```csharp
|
||||
// Stock form
|
||||
private bool showStockForm;
|
||||
private bool showCustomStockForm;
|
||||
private JobStock newStock = new();
|
||||
private JobStock? editingStock;
|
||||
private string? stockErrorMessage;
|
||||
private MaterialShape? stockSelectedShape;
|
||||
private int stockSelectedMaterialId;
|
||||
private List<StockItem> availableStockItems = new();
|
||||
|
||||
// Import modal
|
||||
private bool showImportModal;
|
||||
private bool loadingImport;
|
||||
private List<ImportStockCandidate> importCandidates = new();
|
||||
private string? importErrorMessage;
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```csharp
|
||||
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
|
||||
private enum StockModalTab { Inventory, Custom }
|
||||
private bool showStockModal;
|
||||
private StockModalTab stockModalTab = StockModalTab.Inventory;
|
||||
private JobStock newStock = new();
|
||||
private JobStock? editingStock;
|
||||
private string? stockErrorMessage;
|
||||
private MaterialShape? stockSelectedShape;
|
||||
private int stockSelectedMaterialId;
|
||||
private List<StockItem> availableStockItems = new();
|
||||
private bool loadingImport;
|
||||
private List<ImportStockCandidate> importCandidates = new();
|
||||
private string? importErrorMessage;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `RenderStockTab` to use a single "Add Stock" button and drop the inline form rendering**
|
||||
|
||||
Find (currently around line 737-777):
|
||||
|
||||
```csharp
|
||||
// Stock tab
|
||||
private RenderFragment RenderStockTab() => __builder =>
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Stock for This Job</h5>
|
||||
@if (!job.IsLocked)
|
||||
{
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
|
||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
|
||||
Import from Inventory
|
||||
</button>
|
||||
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showStockForm)
|
||||
{
|
||||
@RenderStockFromInventoryForm()
|
||||
}
|
||||
else if (showCustomStockForm)
|
||||
{
|
||||
@RenderCustomStockForm()
|
||||
}
|
||||
|
||||
@if (job.Stock.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-2">No stock configured for this job.</p>
|
||||
<p class="small">Add stock from your inventory or define custom lengths.</p>
|
||||
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderStockTable()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```csharp
|
||||
// Stock tab
|
||||
private RenderFragment RenderStockTab() => __builder =>
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Stock for This Job</h5>
|
||||
@if (!job.IsLocked)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (job.Stock.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-2">No stock configured for this job.</p>
|
||||
<p class="small">Add stock from your inventory or define custom lengths.</p>
|
||||
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@RenderStockTable()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete `RenderStockFromInventoryForm` and `RenderCustomStockForm`**
|
||||
|
||||
Delete these two methods entirely (currently around lines 779-894, immediately after the new `RenderStockTab` and before `RenderStockTable`):
|
||||
|
||||
```csharp
|
||||
private RenderFragment RenderStockFromInventoryForm() => __builder =>
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
|
||||
...
|
||||
</div>
|
||||
};
|
||||
|
||||
private RenderFragment RenderCustomStockForm() => __builder =>
|
||||
{
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
|
||||
...
|
||||
</div>
|
||||
};
|
||||
```
|
||||
|
||||
(Their field content is reused, without the wrapping `<div class="border...">`/`<h6>`/footer-button markup, inside the new modal in Step 6.)
|
||||
|
||||
- [ ] **Step 4: Replace `ShowAddCustomStock`/`CancelStockForm` with `ShowAddStockModal`/`CloseStockModal`, and rename `ShowImportModal`/`CloseImportModal`**
|
||||
|
||||
Find (currently around line 1230-1246):
|
||||
|
||||
```csharp
|
||||
private void ShowAddCustomStock()
|
||||
{
|
||||
editingStock = null;
|
||||
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
|
||||
stockSelectedShape = null;
|
||||
showStockForm = false;
|
||||
showCustomStockForm = true;
|
||||
stockErrorMessage = null;
|
||||
}
|
||||
|
||||
private void CancelStockForm()
|
||||
{
|
||||
showStockForm = false;
|
||||
showCustomStockForm = false;
|
||||
editingStock = null;
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```csharp
|
||||
private async Task ShowAddStockModal()
|
||||
{
|
||||
editingStock = null;
|
||||
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
|
||||
stockSelectedShape = null;
|
||||
stockSelectedMaterialId = 0;
|
||||
availableStockItems.Clear();
|
||||
stockErrorMessage = null;
|
||||
importErrorMessage = null;
|
||||
importCandidates.Clear();
|
||||
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
|
||||
showStockModal = true;
|
||||
|
||||
if (job.Parts.Count > 0)
|
||||
{
|
||||
await LoadImportCandidatesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseStockModal()
|
||||
{
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
importCandidates.Clear();
|
||||
stockErrorMessage = null;
|
||||
importErrorMessage = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `EditStock` to open the unified modal**
|
||||
|
||||
Find (currently around line 1269-1299):
|
||||
|
||||
```csharp
|
||||
private void EditStock(JobStock stock)
|
||||
{
|
||||
editingStock = stock;
|
||||
newStock = new JobStock
|
||||
{
|
||||
Id = stock.Id,
|
||||
JobId = stock.JobId,
|
||||
MaterialId = stock.MaterialId,
|
||||
StockItemId = stock.StockItemId,
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = stock.Quantity,
|
||||
IsCustomLength = stock.IsCustomLength,
|
||||
Priority = stock.Priority,
|
||||
SortOrder = stock.SortOrder
|
||||
};
|
||||
stockSelectedShape = stock.Material?.Shape;
|
||||
stockSelectedMaterialId = stock.MaterialId;
|
||||
stockErrorMessage = null;
|
||||
|
||||
if (stock.IsCustomLength)
|
||||
{
|
||||
showStockForm = false;
|
||||
showCustomStockForm = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
showStockForm = true;
|
||||
showCustomStockForm = false;
|
||||
_ = OnStockMaterialChanged();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```csharp
|
||||
private void EditStock(JobStock stock)
|
||||
{
|
||||
editingStock = stock;
|
||||
newStock = new JobStock
|
||||
{
|
||||
Id = stock.Id,
|
||||
JobId = stock.JobId,
|
||||
MaterialId = stock.MaterialId,
|
||||
StockItemId = stock.StockItemId,
|
||||
LengthInches = stock.LengthInches,
|
||||
Quantity = stock.Quantity,
|
||||
IsCustomLength = stock.IsCustomLength,
|
||||
Priority = stock.Priority,
|
||||
SortOrder = stock.SortOrder
|
||||
};
|
||||
stockSelectedShape = stock.Material?.Shape;
|
||||
stockSelectedMaterialId = stock.MaterialId;
|
||||
stockErrorMessage = null;
|
||||
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
|
||||
showStockModal = true;
|
||||
|
||||
if (!stock.IsCustomLength)
|
||||
{
|
||||
_ = OnStockMaterialChanged();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update the three save/import completion handlers to close the unified modal**
|
||||
|
||||
In `SaveStockFromInventoryAsync` (currently around line 1349-1353), find:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockForm = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
(this is the end of `SaveStockFromInventoryAsync`) and replace with:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
In `SaveCustomStockAsync` (currently around line 1396-1401), find:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showCustomStockForm = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockModal = false;
|
||||
editingStock = null;
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
In `ImportSelectedStockAsync` (currently around line 1492-1497), find:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showImportModal = false;
|
||||
importCandidates.Clear();
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```csharp
|
||||
job = (await JobService.GetByIdAsync(Id!.Value))!;
|
||||
showStockModal = false;
|
||||
importCandidates.Clear();
|
||||
packResult = null;
|
||||
summary = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Rename `ShowImportModal` to `LoadImportCandidatesAsync` and delete `CloseImportModal`**
|
||||
|
||||
Find (currently around line 1411-1457):
|
||||
|
||||
```csharp
|
||||
// Import modal methods
|
||||
private async Task ShowImportModal()
|
||||
{
|
||||
importErrorMessage = null;
|
||||
importCandidates.Clear();
|
||||
loadingImport = true;
|
||||
showImportModal = true;
|
||||
|
||||
try
|
||||
{
|
||||
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
|
||||
var existingStockItemIds = job.Stock
|
||||
.Where(s => s.StockItemId.HasValue)
|
||||
.Select(s => s.StockItemId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var materialId in materialIds)
|
||||
{
|
||||
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
|
||||
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
|
||||
{
|
||||
importCandidates.Add(new ImportStockCandidate
|
||||
{
|
||||
StockItem = item,
|
||||
Selected = true,
|
||||
Quantity = -1,
|
||||
Priority = 10
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
importErrorMessage = $"Error loading stock: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingImport = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseImportModal()
|
||||
{
|
||||
showImportModal = false;
|
||||
importCandidates.Clear();
|
||||
importErrorMessage = null;
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```csharp
|
||||
// Loads inventory stock candidates for the modal's "From Inventory" tab
|
||||
private async Task LoadImportCandidatesAsync()
|
||||
{
|
||||
importErrorMessage = null;
|
||||
importCandidates.Clear();
|
||||
loadingImport = true;
|
||||
|
||||
try
|
||||
{
|
||||
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
|
||||
var existingStockItemIds = job.Stock
|
||||
.Where(s => s.StockItemId.HasValue)
|
||||
.Select(s => s.StockItemId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var materialId in materialIds)
|
||||
{
|
||||
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
|
||||
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
|
||||
{
|
||||
importCandidates.Add(new ImportStockCandidate
|
||||
{
|
||||
StockItem = item,
|
||||
Selected = true,
|
||||
Quantity = -1,
|
||||
Priority = 10
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
importErrorMessage = $"Error loading stock: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingImport = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Replace the "Import Stock Modal" markup block with the unified "Add/Edit Stock Modal" block**
|
||||
|
||||
Find the entire top-level block currently at lines 232-324:
|
||||
|
||||
```razor
|
||||
@* Import Stock Modal *@
|
||||
@if (showImportModal)
|
||||
{
|
||||
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
(the full existing block — everything between `@* Import Stock Modal *@` and its closing `}`)
|
||||
|
||||
Replace it with:
|
||||
|
||||
```razor
|
||||
@* Add/Edit Stock Modal *@
|
||||
@if (showStockModal)
|
||||
{
|
||||
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (editingStock == null)
|
||||
{
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
|
||||
type="button" disabled="@(job.Parts.Count == 0)"
|
||||
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
|
||||
@onclick="() => stockModalTab = StockModalTab.Inventory">
|
||||
From Inventory
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
|
||||
type="button"
|
||||
@onclick="() => stockModalTab = StockModalTab.Custom">
|
||||
Custom Length
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||
{
|
||||
@if (loadingImport)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<span class="spinner-border"></span>
|
||||
<p class="mt-2 text-muted">Finding matching stock...</p>
|
||||
</div>
|
||||
}
|
||||
else if (importCandidates.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-2">No matching inventory stock found.</p>
|
||||
<p class="small">Either no stock items exist for the materials in your parts, or they have already been added to this job.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(true)">Select All</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(false)">Select None</button>
|
||||
</div>
|
||||
<small class="text-muted">@importCandidates.Count(c => c.Selected) of @importCandidates.Count selected</small>
|
||||
</div>
|
||||
|
||||
@foreach (var group in importCandidates
|
||||
.GroupBy(c => c.StockItem.MaterialId)
|
||||
.OrderBy(g => g.First().StockItem.Material.Shape)
|
||||
.ThenBy(g => g.First().StockItem.Material.Size))
|
||||
{
|
||||
var material = group.First().StockItem.Material;
|
||||
<h6 class="mt-3 mb-2 text-primary">@material.DisplayName</h6>
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th>Length</th>
|
||||
<th style="width: 120px;">Qty to Use</th>
|
||||
<th style="width: 100px;">Priority</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var candidate in group.OrderByDescending(c => c.StockItem.LengthInches))
|
||||
{
|
||||
<tr class="@(candidate.Selected ? "" : "text-muted")">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
|
||||
</td>
|
||||
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
|
||||
min="-1" disabled="@(!candidate.Selected)" />
|
||||
<small class="text-muted">-1 = unlimited</small>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm" @bind="candidate.Priority"
|
||||
min="1" disabled="@(!candidate.Selected)" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(importErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
|
||||
}
|
||||
}
|
||||
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
|
||||
disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Stock Length</label>
|
||||
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var stock in availableStockItems)
|
||||
{
|
||||
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Qty to Use</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">-1 = unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Shape</label>
|
||||
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
|
||||
<option value="">-- Select --</option>
|
||||
@foreach (var shape in DistinctShapes)
|
||||
{
|
||||
<option value="@shape">@shape.GetDisplayName()</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Size</label>
|
||||
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
|
||||
<option value="0">-- Select --</option>
|
||||
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
|
||||
{
|
||||
<option value="@material.Id">@material.Size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Length</label>
|
||||
<LengthInput @bind-Value="newStock.LengthInches" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
|
||||
<small class="text-muted">Use -1 for unlimited</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
|
||||
<small class="text-muted">Lower = used first</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(stockErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
|
||||
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
|
||||
{
|
||||
@if (importCandidates.Count > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
|
||||
disabled="@(!importCandidates.Any(c => c.Selected))">
|
||||
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
|
||||
@(editingStock == null ? "Add Stock" : "Save Changes")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
Note this new block goes in the exact same position (between the "Part Modal Dialog" block and the `@code` block) that the old "Import Stock Modal" block occupied.
|
||||
|
||||
- [ ] **Step 9: Verify no leftover references to removed identifiers**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -nE "showStockForm|showCustomStockForm|showImportModal|ShowAddCustomStock|CancelStockForm|CloseImportModal\b|RenderStockFromInventoryForm|RenderCustomStockForm" CutList.Web/Components/Pages/Jobs/Edit.razor
|
||||
```
|
||||
Expected: no output (all removed/renamed identifiers are gone). If anything matches, fix it before proceeding — it's a leftover reference that Step 10's build may or may not catch (e.g. a stray comment).
|
||||
|
||||
- [ ] **Step 10: Build and verify no compile errors**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build CutList.Web/CutList.Web.csproj
|
||||
```
|
||||
Expected: `Build succeeded.` with 0 errors. Fix any errors (most likely causes: a missed rename, or a Razor `@`-prefix mismatch in the nested `@if` blocks from Step 8 — compare carefully against the nesting depth shown above, every nested C# control-flow keyword inside markup context needs its own `@` prefix).
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add CutList.Web/Components/Pages/Jobs/Edit.razor
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat: unify add/edit stock UI into a single modal on Job Edit page
|
||||
|
||||
Replaces the three separate surfaces (bulk import modal, inline custom-length
|
||||
form, inline edit forms) with one "Add Stock" button and modal that handles
|
||||
both add and edit, for both inventory-sourced and custom-length stock.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Manual verification of every Stock tab flow
|
||||
|
||||
**Files:**
|
||||
- None modified — this task is verification only, using the `webapp-testing` skill (Playwright) against the running app. Fix-forward edits to `CutList.Web/Components/Pages/Jobs/Edit.razor` are in scope if a scenario below reveals a bug.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running `CutList.Web` app (`dotnet run --project CutList.Web/CutList.Web.csproj`, default `http://localhost:5270`) and an existing or newly-created Job on the `/jobs/{id}` Edit page's Stock tab.
|
||||
|
||||
- [ ] **Step 1: Start the app**
|
||||
|
||||
Run (background):
|
||||
```bash
|
||||
dotnet run --project CutList.Web/CutList.Web.csproj
|
||||
```
|
||||
Expected: app listening on `http://localhost:5270`.
|
||||
|
||||
- [ ] **Step 2: Verify "Add Stock" opens the modal defaulting to the Inventory tab when parts exist**
|
||||
|
||||
Using Playwright (webapp-testing skill): navigate to a job that already has at least one part and no stock yet (create one via the Parts tab if none exists), switch to the Stock tab.
|
||||
|
||||
Expected: one "Add Stock" button (no separate "Import from Inventory" / "Add Custom Length" buttons). Clicking it opens a modal titled "Add Stock" with two tabs, "From Inventory" active by default, showing the bulk-select candidate table (or a "No matching inventory stock found" message if the material has no inventory stock items — either is acceptable, both are pre-existing behaviors).
|
||||
|
||||
- [ ] **Step 3: Verify bulk import from the Inventory tab**
|
||||
|
||||
If candidates are present in the table from Step 2 (add a Stock Item via the `/stock/new` page first for the job's material if the list is empty, then reopen the modal), select at least one row's checkbox and click "Import N Item(s)".
|
||||
|
||||
Expected: modal closes, the new stock row appears in the Stock table with the correct material, length, quantity, and priority.
|
||||
|
||||
- [ ] **Step 4: Verify Custom Length tab add flow**
|
||||
|
||||
Reopen "Add Stock", click the "Custom Length" tab, fill in Shape, Size, Length, Quantity, Priority, click "Add Stock".
|
||||
|
||||
Expected: modal closes, the new custom-length row appears in the Stock table tagged with the "Custom" badge.
|
||||
|
||||
- [ ] **Step 5: Verify the Inventory tab is disabled when the job has no parts**
|
||||
|
||||
Create a new job with no parts yet, go to its Stock tab, click "Add Stock".
|
||||
|
||||
Expected: modal opens defaulting to the "Custom Length" tab; the "From Inventory" tab is visibly disabled and shows the "Add parts first to match against inventory" tooltip on hover; clicking it does nothing.
|
||||
|
||||
- [ ] **Step 6: Verify editing an inventory-sourced stock row**
|
||||
|
||||
On a job with an inventory-sourced stock row (from Step 3), click its Edit (pencil) button.
|
||||
|
||||
Expected: modal opens titled "Edit Stock" with no tab nav, showing the single inventory form (Shape/Size/Stock Length dropdown/Qty/Priority) prefilled with the row's current values. Change the quantity and click "Save Changes".
|
||||
|
||||
Expected: modal closes, the table row reflects the updated quantity.
|
||||
|
||||
- [ ] **Step 7: Verify editing a custom-length stock row**
|
||||
|
||||
On the custom-length row (from Step 4), click its Edit button.
|
||||
|
||||
Expected: modal opens titled "Edit Stock" with no tab nav, showing the custom form (Shape/Size/Length/Qty/Priority) prefilled. Change the priority and click "Save Changes".
|
||||
|
||||
Expected: modal closes, the table row reflects the updated priority.
|
||||
|
||||
- [ ] **Step 8: Verify Cancel discards changes in both Add and Edit mode**
|
||||
|
||||
Open "Add Stock", change a few fields on the Custom Length tab, click "Cancel". Reopen and confirm the fields are reset (not retaining the discarded values). Then click Edit on any row, change a field, click "Cancel", and confirm the table row is unchanged.
|
||||
|
||||
- [ ] **Step 9: Verify locked jobs still hide the button**
|
||||
|
||||
Lock a job (via the Results tab's "Lock Job" action, after running Optimize), return to the Stock tab.
|
||||
|
||||
Expected: "Add Stock" button is not rendered (matches pre-existing `@if (!job.IsLocked)` gating, unchanged by this plan) and Edit/Delete icons on stock rows are hidden.
|
||||
|
||||
- [ ] **Step 10: Take a screenshot of the modal in both tab states for the record, then stop the app**
|
||||
|
||||
Capture a screenshot of the modal on the "From Inventory" tab and one on "Custom Length" tab (Playwright `browser_take_screenshot`). Stop the `dotnet run` background process.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Remove inventory quantity tracking from CutList
|
||||
|
||||
## Background
|
||||
|
||||
`StockItem.QuantityOnHand` and its `StockTransaction` ledger (Received/Used/Adjustment/Scrapped/Returned) exist today, but nothing in the job workflow reads or writes them automatically:
|
||||
|
||||
- Adding a stock item to a job (`JobStock`) never checks or reserves `QuantityOnHand` — a user can put `Quantity = 50` on a `JobStock` row pointing at a `StockItem` with 3 on hand and nothing complains.
|
||||
- Locking a job (`JobService.LockAsync`) only stamps `LockedAt` — no transaction is created, `QuantityOnHand` doesn't move.
|
||||
- The only place `QuantityOnHand` has any real effect is `CutListPackingService.PackAsync`'s auto-discovery fallback: when a job has *no* `JobStock` rows configured for a material, the packer pulls all active `StockItem`s for that material, treats the first `QuantityOnHand` bars as free ("in stock"), and always adds an *additional unlimited* bin on top for "to be purchased" — i.e. it silently assumes more can always be bought.
|
||||
- The only way `QuantityOnHand` actually changes is a fully separate, fully manual flow (`StockItemsController` receive/use/adjust/scrap, driven from the `/stock/{id}` page) that nothing in the Jobs UI ever triggers.
|
||||
|
||||
Net effect: inventory quantity is a second, disconnected bookkeeping system that the job workflow neither respects nor maintains, and the auto-discovery fallback actively assumes unlimited purchasing beyond whatever quantity happens to be tracked. Decision: remove quantity tracking entirely. A job's stock should be exactly what the user explicitly enters for that job — nothing assumed, nothing silently topped up — and the user should never need to think about a separate "inventory" system to enter parts, stock, and get results.
|
||||
|
||||
## Scope
|
||||
|
||||
### Data model
|
||||
|
||||
- `StockItem` drops `QuantityOnHand`. Keeps `MaterialId`, `LengthInches`, `Name`, `Notes`, `IsActive`, `CreatedAt`/`UpdatedAt` — it becomes a pure catalog of known lengths per material (still used to populate the "pick a standard length" dropdown when adding stock to a job).
|
||||
- `StockTransaction` entity and `StockTransactionType` enum: **deleted entirely**, along with the `ApplicationDbContext.StockTransactions` `DbSet` and its EF configuration.
|
||||
- `JobStock` is unchanged in shape. Its `Quantity` becomes the sole source of truth for how many bars are available to a job everywhere it's used: a finite number is a hard ceiling (parts beyond it land in `ItemsNotPlaced`, nothing is silently treated as purchasable), and `-1` (unlimited) only applies when the user explicitly picks it. This "unlimited" option is extended to catalog-sourced `JobStock` rows, which today only allow it for custom-length rows.
|
||||
|
||||
### Services / API
|
||||
|
||||
- `StockItemService` drops `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`, `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`. Keeps `GetAllAsync`, `GetByMaterialAsync`, `GetByIdAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `ExistsAsync`.
|
||||
- `StockItemsController` drops `POST /{id}/receive`, `POST /{id}/use`, `POST /{id}/adjust`, `POST /{id}/scrap`, `POST /{id}/recalculate`, `GET /{id}/transactions`. Keeps list/get/create/update/delete/by-material.
|
||||
- `StockItemDto` drops `QuantityOnHand`; `CreateStockItemDto` drops `QuantityOnHand`. `StockTransactionDto`, `AddStockDto`, `UseStockDto`, `AdjustStockDto`, `ScrapStockDto` are deleted.
|
||||
- `CutListPackingService.PackAsync` drops the auto-discovery fallback branch entirely (the "no job-specific stock configured" path that reads `QuantityOnHand` and adds an unlimited purchasable bin). A material with no `JobStock` rows configured packs zero stock bins, so all of its parts land in `ItemsNotPlaced` — same code path as today's "no stock available" case. The `IsInStock` bin classification (`!IsCustomLength && StockItemId.HasValue`) is untouched, since it already doesn't depend on quantity.
|
||||
- `CatalogService`: `CatalogStockItemDto` drops `QuantityOnHand`; `ImportStockItemsAsync`/`MapStockItems` stop reading/writing it. Existing seed JSON (`alro-catalog.json`, `oneals-catalog.json`) keeps the field in the file for now — it's simply ignored on import.
|
||||
- `CutList.Mcp/InventoryTools.cs`: its own `StockItemDto` drops `QuantityOnHand`. `add_stock_item` drops the `quantityOnHand` parameter. `add_stock` convenience tool drops `quantityOnHand` and the `QuantityOnHand` field on `AddStockResult` — it becomes purely "ensure this material and stock length exist." `ApiClient` methods that pass `quantityOnHand` are updated to match. Republished to `~/.claude/mcp/CutList.Mcp/` per the standard MCP publishing workflow.
|
||||
|
||||
### UI
|
||||
|
||||
- `/stock` (Index): drop the "On Hand" column/badge. Intro copy changes from "tracks how many pieces you have on hand" to describing stock items as the lengths of material available to cut from.
|
||||
- `/stock/{id}` (Edit): drop the entire right-hand "Inventory" card (quantity badge, Add/Adjust Stock transaction form, transaction history table). Left-hand details form (Material, Length, Name, Notes) is unchanged.
|
||||
- Job Edit → Stock tab: `SaveStockFromInventoryAsync`'s quantity validation changes from `Quantity < 1` to `Quantity < -1 || Quantity == 0` (matching custom-stock validation), and the form gains an "Unlimited" option for catalog-sourced rows.
|
||||
- Job Edit → Results tab: the existing "Items Not Placed" warning stays as-is structurally; its copy changes from "No stock lengths available or parts too long" to also cover insufficient configured quantity, since a finite `JobStock.Quantity` is now the only thing that can produce unplaced items for a material that does have stock configured.
|
||||
|
||||
### Migration
|
||||
|
||||
One new EF Core migration:
|
||||
- Drops the `QuantityOnHand` column from `StockItems`.
|
||||
- Drops the `StockTransactions` table.
|
||||
|
||||
This is destructive to any `QuantityOnHand`/`StockTransaction` data currently on forge (confirmed acceptable — nothing reads it automatically today, and it isn't otherwise relied upon). Applied immediately after `dotnet ef migrations add`, per the standard EF workflow, no separate confirmation gate beyond this design doc.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to the packing algorithm itself (`CutList.Core`) — untouched, since bin selection already only consumed `StockBinSource.Quantity`/`IsInStock`, not `QuantityOnHand` directly.
|
||||
- Reworking the "Purchase List" / `ToBePurchasedBins` reporting concept — it stays as a classification of catalog-sourced vs. custom-length bins for the print report, which doesn't depend on quantity tracking.
|
||||
- Cleaning up `QuantityOnHand` values already present in `alro-catalog.json`/`oneals-catalog.json` seed files — harmless once the import path ignores the field.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Unified Add/Edit Stock Modal — Job Edit Page, Stock Tab
|
||||
|
||||
## Problem
|
||||
|
||||
The Stock tab on the Job Edit page (`CutList.Web/Components/Pages/Jobs/Edit.razor`) currently exposes three separate, inconsistent UI surfaces for adding/editing job stock:
|
||||
|
||||
1. **Import from Inventory** button → opens a full modal (`showImportModal`) with a bulk multi-select table (grouped by material, checkboxes, Select All/None, per-row qty/priority). Disabled entirely when the job has no parts.
|
||||
2. **Add Custom Length** button → toggles an inline (non-modal) single-row form (`showCustomStockForm`) in the card body.
|
||||
3. **Edit** (pencil icon on an existing stock row) → reuses one of two inline single-row forms depending on the item type: the bulk-import modal's sibling single form (`showStockForm`, inventory-sourced) or the custom form (`showCustomStockForm`), neither as a modal for the inventory case's add path (only reachable via Edit today).
|
||||
|
||||
This is confusing: two different buttons for adding, plus edit behavior that doesn't match either add flow.
|
||||
|
||||
## Goal
|
||||
|
||||
Consolidate all of this into a single **"Add Stock"** button and one modal that handles both adding and editing, for both inventory-sourced and custom-length stock.
|
||||
|
||||
## State Model
|
||||
|
||||
Replace the three separate visibility flags with:
|
||||
|
||||
- `showStockModal` (bool) — single modal visibility flag, replacing `showStockForm`, `showCustomStockForm`, and `showImportModal`.
|
||||
- `stockModalTab` (enum: `Inventory`, `Custom`) — which tab is active. Only switchable in Add mode.
|
||||
- `editingStock` (existing field) — `null` means Add mode; non-null means Edit mode, and fixes which tab is shown.
|
||||
|
||||
`newStock`, `importCandidates`, `stockErrorMessage`, `importErrorMessage`, `availableStockItems`, `stockSelectedShape`, `stockSelectedMaterialId` are all reused as-is from the current implementation.
|
||||
|
||||
## Header
|
||||
|
||||
The "Import from Inventory" and "Add Custom Length" buttons are replaced with a single **"Add Stock"** button. It is always enabled (not gated on `job.Parts.Count`). Clicking it:
|
||||
|
||||
- Sets `editingStock = null`.
|
||||
- Opens the modal (`showStockModal = true`).
|
||||
- Defaults `stockModalTab` to `Inventory` if `job.Parts.Count > 0`, otherwise `Custom`.
|
||||
|
||||
## Modal — Add Mode (`editingStock == null`)
|
||||
|
||||
Nav tabs shown: **From Inventory** | **Custom Length**.
|
||||
|
||||
- The **From Inventory** tab is disabled (with the existing tooltip copy, "Add parts first to match against inventory") when `job.Parts.Count == 0`. Its content is today's bulk-select table verbatim, reusing `importCandidates` and the existing `ShowImportModal` loading logic (grouped by material, Select All/None, per-row qty/priority inputs).
|
||||
- The **Custom Length** tab content is today's single-row custom form verbatim (shape → size → length → qty → priority).
|
||||
- Footer's primary button adapts to the active tab:
|
||||
- Inventory tab: "Import N Item(s)", disabled when no candidates are selected — wired to `ImportSelectedStockAsync`.
|
||||
- Custom tab: "Add Stock" — wired to `SaveCustomStockAsync`.
|
||||
- "Cancel" button always present, closes the modal and resets `editingStock`, `newStock`, `importCandidates`, and error messages.
|
||||
|
||||
## Modal — Edit Mode (`editingStock != null`)
|
||||
|
||||
No tab nav is shown — a single fixed form matching the item's existing type:
|
||||
|
||||
- If `editingStock.IsCustomLength`: renders the same custom-form fields (shape → size → length → qty → priority), prefilled from the stock row — wired to `SaveCustomStockAsync`.
|
||||
- Else: renders the same single-item inventory form fields (shape → size → stock-length dropdown → qty → priority), prefilled — wired to `SaveStockFromInventoryAsync`.
|
||||
- Footer primary button: "Save Changes".
|
||||
- "Cancel" button behaves as above.
|
||||
|
||||
`EditStock(stock)` sets `editingStock`, prefills `newStock` (as it does today), sets `stockModalTab` to match the item's type, and opens `showStockModal = true`.
|
||||
|
||||
## Method Reuse
|
||||
|
||||
The four existing handler methods keep their current logic unchanged — only the surrounding modal chrome (open/close state, which form renders) changes:
|
||||
|
||||
- `SaveStockFromInventoryAsync` — single inventory-sourced add/edit.
|
||||
- `SaveCustomStockAsync` — single custom-length add/edit.
|
||||
- `ImportSelectedStockAsync` — bulk inventory import (add only).
|
||||
- `ShowImportModal` (the loading logic, not the modal-open flag itself) — populates `importCandidates` when the Inventory tab is active/opened in Add mode.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Unchanged. Each tab/mode keeps its own existing inline `alert-danger` validation message (`stockErrorMessage` for the two single-item forms, `importErrorMessage` for the bulk table).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- No changes to the Parts tab or its modal.
|
||||
- No changes to the underlying `JobService` methods or API surface.
|
||||
- No change to validation rules or business logic — this is a UI consolidation only.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Alro Steel SmartGrid Scraper — Remaining Steps
|
||||
|
||||
## Status: Script is READY TO RUN
|
||||
|
||||
The scraper at `scripts/AlroCatalog/scrape_alro.py` is complete and tested. Discovery mode confirmed it works correctly against the live site.
|
||||
|
||||
## What's Done
|
||||
1. Script written with correct ASP.NET control IDs (discovered via `--discover` mode)
|
||||
2. Level 1 (main grid) navigation: working
|
||||
3. Level 2 (popup grid) navigation: working
|
||||
4. Level 3 (dims panel) scraping: working — uses cascading dropdowns `ddlDimA` → `ddlDimB` → `ddlDimC` → `ddlLength`
|
||||
5. Grade filter: 11 common grades (A-36, 1018, 1045, 1144, 12L14, etc.)
|
||||
6. Size string normalization: "1-1/2\"" matches O'Neal format
|
||||
7. Progress save/resume: working
|
||||
8. Discovery mode verified: A-36 Round bars → 27 sizes, 80 items (lengths include "20 FT", "Custom Cut List", "Drop/Remnant" — non-stock entries filtered out in catalog builder)
|
||||
|
||||
## Remaining Steps
|
||||
|
||||
### Step 1: Run the full scrape
|
||||
```bash
|
||||
cd C:\Users\aisaacs\Desktop\Projects\CutList
|
||||
python scripts/AlroCatalog/scrape_alro.py
|
||||
```
|
||||
- This scrapes all 3 categories (Bars, Pipe/Tube, Structural) for 11 filtered grades
|
||||
- Takes ~30-60 minutes (cascading dropdown selections with 1.5s delay each)
|
||||
- Progress saved incrementally to `scripts/AlroCatalog/alro-scrape-progress.json`
|
||||
- If interrupted, resume with `python scripts/AlroCatalog/scrape_alro.py --resume`
|
||||
- To scrape ALL grades: `python scripts/AlroCatalog/scrape_alro.py --all-grades`
|
||||
|
||||
### Step 2: Review output
|
||||
- Output: `CutList.Web/Data/SeedData/alro-catalog.json`
|
||||
- Verify material counts, shapes, sizes
|
||||
- Spot-check dimensions against myalro.com
|
||||
- Compare shape coverage to O'Neal catalog
|
||||
|
||||
### Step 3: Post-scrape adjustments (if needed)
|
||||
|
||||
**Dimension mapping for Structural/Pipe shapes**: The `build_size_and_dims()` function handles all shapes but Structural (Angle, Channel, Beam) and Pipe/Tube shapes haven't been tested live yet. After scraping, check the screenshots in `scripts/AlroCatalog/screenshots/` to verify dimension mapping. The first item of each new shape gets a screenshot + HTML dump.
|
||||
|
||||
**Known dimension mapping assumptions:**
|
||||
- Angle: DimA = leg size, DimB = thickness → `"leg1 x leg2 x thickness"` (assumes equal legs)
|
||||
- Channel: DimA = height, DimB = flange → needs verification
|
||||
- IBeam: DimA = depth, DimB = weight/ft → `"W{depth} x {wt}"`
|
||||
- SquareTube: DimA = size, DimB = wall
|
||||
- RectTube: DimA = width, DimB = height, DimC = wall
|
||||
- RoundTube: DimA = OD, DimB = wall
|
||||
- Pipe: DimA = NPS, DimB = schedule
|
||||
|
||||
**If dimension mapping is wrong for a shape**: Edit the `build_size_and_dims()` function in `scrape_alro.py` and re-run just the catalog builder:
|
||||
```python
|
||||
python -c "
|
||||
import json
|
||||
from scripts.AlroCatalog.scrape_alro import build_catalog
|
||||
data = json.load(open('scripts/AlroCatalog/alro-scrape-progress.json'))
|
||||
catalog = build_catalog(data['items'])
|
||||
json.dump(catalog, open('CutList.Web/Data/SeedData/alro-catalog.json', 'w'), indent=2)
|
||||
"
|
||||
```
|
||||
|
||||
### Step 4: Part numbers (optional future enhancement)
|
||||
The current scraper captures sizes and lengths but NOT part numbers. To get part numbers, the script would need to:
|
||||
1. Select DimA + DimB + Length
|
||||
2. Click the "Next >" button (`btnSearch`)
|
||||
3. Capture part number from the results panel
|
||||
4. Click Back
|
||||
|
||||
This adds significant time per item. The catalog works without part numbers — the supplierOfferings have empty partNumber/supplierDescription fields.
|
||||
|
||||
## Key Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `scripts/AlroCatalog/scrape_alro.py` | The scraper script |
|
||||
| `scripts/AlroCatalog/alro-scrape-progress.json` | Incremental progress (resume support) |
|
||||
| `scripts/AlroCatalog/screenshots/` | Discovery HTML/screenshots per shape |
|
||||
| `CutList.Web/Data/SeedData/alro-catalog.json` | Final output (same schema as oneals-catalog.json) |
|
||||
| `CutList.Web/Data/SeedData/oneals-catalog.json` | Reference format |
|
||||
|
||||
## Grade Filter (editable in script)
|
||||
Located at line ~50 in `scrape_alro.py`. Current filter:
|
||||
- A-36, 1018 CF, 1018 HR, 1044 HR, 1045 CF, 1045 HR, 1045 TG&P
|
||||
- 1144 CF, 1144 HR, 12L14 CF, A311/Stressproof
|
||||
|
||||
To add/remove grades, edit the `GRADE_FILTER` set in the script.
|
||||
@@ -1,976 +0,0 @@
|
||||
{
|
||||
"completed": [
|
||||
[
|
||||
"Bars",
|
||||
"A-36",
|
||||
"ROUND"
|
||||
],
|
||||
[
|
||||
"Bars",
|
||||
"A-36",
|
||||
"FLAT"
|
||||
]
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".188",
|
||||
"dim_a_text": "3/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".188",
|
||||
"dim_a_text": "3/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".250",
|
||||
"dim_a_text": "1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".250",
|
||||
"dim_a_text": "1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".250",
|
||||
"dim_a_text": "1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".313",
|
||||
"dim_a_text": "5/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".313",
|
||||
"dim_a_text": "5/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".313",
|
||||
"dim_a_text": "5/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".375",
|
||||
"dim_a_text": "3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".375",
|
||||
"dim_a_text": "3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".375",
|
||||
"dim_a_text": "3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".438",
|
||||
"dim_a_text": "7/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".438",
|
||||
"dim_a_text": "7/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".438",
|
||||
"dim_a_text": "7/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".500",
|
||||
"dim_a_text": "1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".500",
|
||||
"dim_a_text": "1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".500",
|
||||
"dim_a_text": "1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".563",
|
||||
"dim_a_text": "9/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".563",
|
||||
"dim_a_text": "9/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".563",
|
||||
"dim_a_text": "9/16",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".625",
|
||||
"dim_a_text": "5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".625",
|
||||
"dim_a_text": "5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".625",
|
||||
"dim_a_text": "5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".750",
|
||||
"dim_a_text": "3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".750",
|
||||
"dim_a_text": "3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".750",
|
||||
"dim_a_text": "3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".875",
|
||||
"dim_a_text": "7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".875",
|
||||
"dim_a_text": "7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": ".875",
|
||||
"dim_a_text": "7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.000",
|
||||
"dim_a_text": "1",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.000",
|
||||
"dim_a_text": "1",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.000",
|
||||
"dim_a_text": "1",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.125",
|
||||
"dim_a_text": "1 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.125",
|
||||
"dim_a_text": "1 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.125",
|
||||
"dim_a_text": "1 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.250",
|
||||
"dim_a_text": "1 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.250",
|
||||
"dim_a_text": "1 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.250",
|
||||
"dim_a_text": "1 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.375",
|
||||
"dim_a_text": "1 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.375",
|
||||
"dim_a_text": "1 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.375",
|
||||
"dim_a_text": "1 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.500",
|
||||
"dim_a_text": "1 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.500",
|
||||
"dim_a_text": "1 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.500",
|
||||
"dim_a_text": "1 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.625",
|
||||
"dim_a_text": "1 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.625",
|
||||
"dim_a_text": "1 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.625",
|
||||
"dim_a_text": "1 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.750",
|
||||
"dim_a_text": "1 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.750",
|
||||
"dim_a_text": "1 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.750",
|
||||
"dim_a_text": "1 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.875",
|
||||
"dim_a_text": "1 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.875",
|
||||
"dim_a_text": "1 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "1.875",
|
||||
"dim_a_text": "1 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.000",
|
||||
"dim_a_text": "2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.000",
|
||||
"dim_a_text": "2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.000",
|
||||
"dim_a_text": "2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.125",
|
||||
"dim_a_text": "2 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.125",
|
||||
"dim_a_text": "2 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.125",
|
||||
"dim_a_text": "2 1/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.250",
|
||||
"dim_a_text": "2 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.250",
|
||||
"dim_a_text": "2 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.250",
|
||||
"dim_a_text": "2 1/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.375",
|
||||
"dim_a_text": "2 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.375",
|
||||
"dim_a_text": "2 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.375",
|
||||
"dim_a_text": "2 3/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.500",
|
||||
"dim_a_text": "2 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.500",
|
||||
"dim_a_text": "2 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.500",
|
||||
"dim_a_text": "2 1/2",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.625",
|
||||
"dim_a_text": "2 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.625",
|
||||
"dim_a_text": "2 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.625",
|
||||
"dim_a_text": "2 5/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.750",
|
||||
"dim_a_text": "2 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.750",
|
||||
"dim_a_text": "2 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.750",
|
||||
"dim_a_text": "2 3/4",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.875",
|
||||
"dim_a_text": "2 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.875",
|
||||
"dim_a_text": "2 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "2.875",
|
||||
"dim_a_text": "2 7/8",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "3.000",
|
||||
"dim_a_text": "3",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Custom Cut List",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "3.000",
|
||||
"dim_a_text": "3",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "Drop/Remnant",
|
||||
"length_inches": null
|
||||
},
|
||||
{
|
||||
"grade": "A-36",
|
||||
"shape": "RoundBar",
|
||||
"dim_a_val": "3.000",
|
||||
"dim_a_text": "3",
|
||||
"dim_b_val": null,
|
||||
"dim_b_text": null,
|
||||
"dim_c_val": null,
|
||||
"dim_c_text": null,
|
||||
"length_text": "20 FT",
|
||||
"length_inches": 240.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,790 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Alro Steel SmartGrid Scraper
|
||||
Scrapes myalro.com's SmartGrid for Carbon Steel materials and outputs
|
||||
a catalog JSON matching the O'Neal catalog format.
|
||||
|
||||
Usage:
|
||||
python scrape_alro.py # Scrape filtered grades (resumes from saved progress)
|
||||
python scrape_alro.py --all-grades # Scrape ALL grades (slow)
|
||||
python scrape_alro.py --discover # Scrape first item only, dump HTML/screenshots
|
||||
python scrape_alro.py --fresh # Start fresh, ignoring saved progress
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from playwright.async_api import async_playwright, Page, TimeoutError as PwTimeout
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
OUTPUT_PATH = (SCRIPT_DIR / "../../CutList.Web/Data/SeedData/alro-catalog.json").resolve()
|
||||
PROGRESS_PATH = SCRIPT_DIR / "alro-scrape-progress.json"
|
||||
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────
|
||||
BASE_URL = "https://www.myalro.com/SmartGrid.aspx?PT=Steel&Clear=true"
|
||||
DELAY = 5 # seconds between postback clicks
|
||||
TIMEOUT = 15_000 # ms for element waits
|
||||
CS_ROW = 4 # Carbon Steel row index in main grid
|
||||
|
||||
CATEGORIES = ["Bars", "Pipe / Tube", "Structural"]
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────┐
|
||||
# │ GRADE FILTER — only these grades will be scraped. │
|
||||
# │ Use --all-grades flag to override and scrape everything. │
|
||||
# │ Grade names must match the gpname attribute exactly. │
|
||||
# └─────────────────────────────────────────────────────────────────┘
|
||||
GRADE_FILTER = {
|
||||
# Common structural / general purpose
|
||||
"A-36",
|
||||
# Mild steel
|
||||
"1018 CF",
|
||||
"1018 HR",
|
||||
# Medium carbon (shafts, gears, pins)
|
||||
"1045 CF",
|
||||
"1045 HR",
|
||||
"1045 TG&P",
|
||||
# Free-machining
|
||||
"1144 CF",
|
||||
"1144 HR",
|
||||
"12L14 CF",
|
||||
# Hot-rolled plate/bar
|
||||
"1044 HR",
|
||||
# Stressproof (high-strength shafting)
|
||||
"A311/Stressproof",
|
||||
}
|
||||
|
||||
# Alro shape column header → our MaterialShape enum
|
||||
SHAPE_MAP = {
|
||||
"ROUND": "RoundBar",
|
||||
"FLAT": "FlatBar",
|
||||
"SQUARE": "SquareBar",
|
||||
"ANGLE": "Angle",
|
||||
"CHANNEL": "Channel",
|
||||
"BEAM": "IBeam",
|
||||
"SQ TUBE": "SquareTube",
|
||||
"SQUARE TUBE": "SquareTube",
|
||||
"REC TUBE": "RectangularTube",
|
||||
"RECT TUBE": "RectangularTube",
|
||||
"RECTANGULAR TUBE": "RectangularTube",
|
||||
"ROUND TUBE": "RoundTube",
|
||||
"RND TUBE": "RoundTube",
|
||||
"PIPE": "Pipe",
|
||||
}
|
||||
|
||||
# ── ASP.NET control IDs ─────────────────────────────────────────
|
||||
_CP = "ctl00_ContentPlaceHolder1"
|
||||
_PU = f"{_CP}_pnlPopUP"
|
||||
ID = dict(
|
||||
main_grid = f"{_CP}_grdMain",
|
||||
popup_grid = f"{_PU}_grdPopUp",
|
||||
popup_window = f"{_PU}_Window",
|
||||
dims_panel = f"{_PU}_upnlDims",
|
||||
back_btn = f"{_PU}_btnBack",
|
||||
# Dimension dropdowns (cascading: A → B → C → Length)
|
||||
dim_a = f"{_PU}_ddlDimA",
|
||||
dim_b = f"{_PU}_ddlDimB",
|
||||
dim_c = f"{_PU}_ddlDimC",
|
||||
dim_length = f"{_PU}_ddlLength",
|
||||
btn_next = f"{_PU}_btnSearch",
|
||||
)
|
||||
|
||||
# Postback targets ($ separators)
|
||||
PB = dict(
|
||||
main_grid = "ctl00$ContentPlaceHolder1$grdMain",
|
||||
popup_grid = "ctl00$ContentPlaceHolder1$pnlPopUP$grdPopUp",
|
||||
back_btn = "ctl00$ContentPlaceHolder1$pnlPopUP$btnBack",
|
||||
popup = "ctl00$ContentPlaceHolder1$pnlPopUP",
|
||||
dim_a = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimA",
|
||||
dim_b = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimB",
|
||||
dim_c = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimC",
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Utility helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def parse_fraction(s: str) -> float | None:
|
||||
"""Parse fraction/decimal string → float. '1-1/4' → 1.25, '.250' → 0.25"""
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip().strip('"\'')
|
||||
# Collapse double spaces from Alro dropdown text ("1 1/4" → "1 1/4")
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
pass
|
||||
# Mixed fraction: "1-1/4" or "1 1/4"
|
||||
m = re.match(r"^(\d+)[\s-](\d+)/(\d+)$", s)
|
||||
if m:
|
||||
return int(m[1]) + int(m[2]) / int(m[3])
|
||||
m = re.match(r"^(\d+)/(\d+)$", s)
|
||||
if m:
|
||||
return int(m[1]) / int(m[2])
|
||||
m = re.match(r"^(\d+)$", s)
|
||||
if m:
|
||||
return float(m[1])
|
||||
return None
|
||||
|
||||
|
||||
def decimal_to_fraction(value: float) -> str:
|
||||
"""0.25 → '1/4', 1.25 → '1-1/4', 3.0 → '3'"""
|
||||
if value <= 0:
|
||||
return "0"
|
||||
whole = int(value)
|
||||
frac = value - whole
|
||||
if abs(frac) < 0.001:
|
||||
return str(whole)
|
||||
from math import gcd
|
||||
sixteenths = round(frac * 16)
|
||||
if sixteenths == 16:
|
||||
return str(whole + 1)
|
||||
g = gcd(sixteenths, 16)
|
||||
num, den = sixteenths // g, 16 // g
|
||||
frac_s = f"{num}/{den}"
|
||||
return f"{whole}-{frac_s}" if whole else frac_s
|
||||
|
||||
|
||||
def normalize_dim_text(s: str) -> str:
|
||||
"""Normalize dimension text: '1 1/4' → '1-1/4', '3/16' → '3/16'"""
|
||||
s = re.sub(r"\s+", " ", s.strip())
|
||||
# "1 1/4" → "1-1/4" (mixed fraction with space → hyphen)
|
||||
s = re.sub(r"^(\d+)\s+(\d+/\d+)$", r"\1-\2", s)
|
||||
return s
|
||||
|
||||
|
||||
def parse_length_to_inches(text: str) -> float | None:
|
||||
"""Parse length string to inches. \"20'\" → 240, \"240\" → 240"""
|
||||
s = text.strip().upper()
|
||||
s = re.sub(r"\s*(RL|RANDOM.*|LENGTHS?|EA|EACH|STOCK)\s*", "", s).strip()
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*['\u2032]", s)
|
||||
if m:
|
||||
return float(m[1]) * 12
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*FT", s)
|
||||
if m:
|
||||
return float(m[1]) * 12
|
||||
m = re.match(r'^(\d+(?:\.\d+)?)\s*"?\s*$', s)
|
||||
if m:
|
||||
v = float(m[1])
|
||||
return v * 12 if v <= 30 else v
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SmartGrid navigation
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def wait_for_update(page: Page, timeout: int = TIMEOUT):
|
||||
"""Wait for ASP.NET partial postback to finish."""
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=timeout)
|
||||
except PwTimeout:
|
||||
log.warning(" networkidle timeout – continuing")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def do_postback(page: Page, target: str, arg: str):
|
||||
"""Execute a __doPostBack call."""
|
||||
await page.evaluate(f"__doPostBack('{target}', '{arg}')")
|
||||
|
||||
|
||||
async def click_category(page: Page, category: str) -> bool:
|
||||
"""Click a category blue-button for Carbon Steel in the main grid."""
|
||||
log.info(f"Clicking main grid: {category} (row {CS_ROW})")
|
||||
arg = f"{category}${CS_ROW}"
|
||||
link = await page.query_selector(
|
||||
f"#{ID['main_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
||||
)
|
||||
if not link:
|
||||
log.error(f" Button not found for {arg}")
|
||||
return False
|
||||
|
||||
parent = await link.evaluate_handle("el => el.parentElement")
|
||||
await parent.as_element().click()
|
||||
|
||||
try:
|
||||
await page.wait_for_selector(f"#{ID['popup_grid']}", state="visible", timeout=TIMEOUT)
|
||||
await wait_for_update(page)
|
||||
return True
|
||||
except PwTimeout:
|
||||
log.error(f" Popup did not appear for {category}")
|
||||
return False
|
||||
|
||||
|
||||
async def scrape_popup_grid(page: Page):
|
||||
"""Parse the popup grid → [(grade_name, grade_id, shape, row_idx, has_btn)]."""
|
||||
headers = await page.eval_on_selector_all(
|
||||
f"#{ID['popup_grid']} tr.DataHeader th",
|
||||
"els => els.map(el => el.textContent.trim())",
|
||||
)
|
||||
log.info(f" Popup columns: {headers}")
|
||||
|
||||
rows = await page.query_selector_all(
|
||||
f"#{ID['popup_grid']} tr.griditemP, #{ID['popup_grid']} tr.gridaltItemP"
|
||||
)
|
||||
combos = []
|
||||
for row_idx, row in enumerate(rows):
|
||||
first_td = await row.query_selector("td[gpid]")
|
||||
if not first_td:
|
||||
continue
|
||||
gid = (await first_td.get_attribute("gpid") or "").strip()
|
||||
gname = (await first_td.get_attribute("gpname") or "").strip()
|
||||
tds = await row.query_selector_all("td")
|
||||
for col_idx, td in enumerate(tds):
|
||||
if col_idx == 0:
|
||||
continue
|
||||
shape = headers[col_idx] if col_idx < len(headers) else ""
|
||||
img = await td.query_selector("img[src*='blue_button']")
|
||||
combos.append((gname, gid, shape, row_idx, img is not None))
|
||||
|
||||
active = sum(1 for c in combos if c[4])
|
||||
log.info(f" {active} active grade/shape combos")
|
||||
return combos
|
||||
|
||||
|
||||
async def click_shape(page: Page, shape: str, row_idx: int) -> bool:
|
||||
"""Click a shape button in the popup grid; wait for dims panel."""
|
||||
arg = f"{shape}${row_idx}"
|
||||
link = await page.query_selector(
|
||||
f"#{ID['popup_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
|
||||
)
|
||||
if not link:
|
||||
try:
|
||||
await do_postback(page, PB["popup_grid"], arg)
|
||||
except Exception:
|
||||
log.warning(f" Could not click shape {arg}")
|
||||
return False
|
||||
else:
|
||||
parent = await link.evaluate_handle("el => el.parentElement")
|
||||
await parent.as_element().click()
|
||||
|
||||
try:
|
||||
# Wait for the DimA dropdown to appear (the real indicator of dims panel loaded)
|
||||
await page.wait_for_selector(f"#{ID['dim_a']}", state="attached", timeout=TIMEOUT)
|
||||
await wait_for_update(page)
|
||||
return True
|
||||
except PwTimeout:
|
||||
# Check if panel has any content at all
|
||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
||||
if len(html.strip()) > 50:
|
||||
await wait_for_update(page)
|
||||
return True
|
||||
log.warning(f" Dims panel timeout for {arg}")
|
||||
return False
|
||||
|
||||
|
||||
async def click_back(page: Page):
|
||||
"""Click Back to return to the popup grid view."""
|
||||
try:
|
||||
await do_postback(page, PB["back_btn"], "")
|
||||
await wait_for_update(page)
|
||||
await asyncio.sleep(DELAY)
|
||||
except Exception as e:
|
||||
log.warning(f" Back button error: {e}")
|
||||
|
||||
|
||||
async def close_popup(page: Page):
|
||||
"""Close the popup window and return to the main grid."""
|
||||
try:
|
||||
await do_postback(page, PB["popup"], "Close")
|
||||
await wait_for_update(page)
|
||||
await asyncio.sleep(DELAY)
|
||||
except Exception as e:
|
||||
log.warning(f" Close popup error: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Level 3 — Dimension Panel Scraping
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def get_select_options(page: Page, sel_id: str):
|
||||
"""Return [(value, text), ...] for a <select>, excluding placeholders."""
|
||||
el = await page.query_selector(f"#{sel_id}")
|
||||
if not el:
|
||||
return []
|
||||
# Check if disabled
|
||||
disabled = await el.get_attribute("disabled")
|
||||
if disabled:
|
||||
return []
|
||||
try:
|
||||
opts = await page.eval_on_selector(
|
||||
f"#{sel_id}",
|
||||
"""el => Array.from(el.options).map(o => ({
|
||||
v: o.value, t: o.text.trim(), d: o.disabled
|
||||
}))""",
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return [
|
||||
(o["v"], o["t"])
|
||||
for o in opts
|
||||
if o["v"] and o["v"] != "-1" and o["t"] and not o["d"]
|
||||
and o["t"].lower() not in ("- select -", "--select--", "select...", "select", "")
|
||||
]
|
||||
|
||||
|
||||
async def scrape_dims_panel(page: Page, grade: str, shape_alro: str,
|
||||
shape_mapped: str, *, save_discovery: bool = False,
|
||||
on_item=None, scraped_dim_a: set[str] | None = None):
|
||||
"""Main Level 3 extraction. Returns list of raw item dicts.
|
||||
|
||||
If on_item callback is provided, it is called with each item dict
|
||||
as soon as it is discovered (for incremental saving).
|
||||
If scraped_dim_a is provided, DimA values in that set are skipped (resume).
|
||||
"""
|
||||
items: list[dict] = []
|
||||
|
||||
if save_discovery:
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
safe = f"{grade}_{shape_alro}".replace(" ", "_").replace("/", "-")
|
||||
await page.screenshot(path=str(SCREENSHOTS_DIR / f"dims_{safe}.png"), full_page=True)
|
||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
||||
(SCREENSHOTS_DIR / f"dims_{safe}.html").write_text(html, encoding="utf-8")
|
||||
log.info(f" Discovery saved → screenshots/dims_{safe}.*")
|
||||
|
||||
# ── Get DimA options (primary dimension: diameter, width, size, etc.) ──
|
||||
dim_a_opts = await get_select_options(page, ID["dim_a"])
|
||||
if not dim_a_opts:
|
||||
log.warning(f" No DimA options found")
|
||||
try:
|
||||
html = await page.inner_html(f"#{ID['dims_panel']}")
|
||||
if len(html) > 50:
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
safe = f"{grade}_{shape_alro}_nodimopts".replace(" ", "_").replace("/", "-")
|
||||
(SCREENSHOTS_DIR / f"{safe}.html").write_text(html, encoding="utf-8")
|
||||
except Exception as e:
|
||||
log.warning(f" Could not dump dims panel: {e}")
|
||||
return []
|
||||
|
||||
already_done = scraped_dim_a or set()
|
||||
remaining = [(v, t) for v, t in dim_a_opts if v not in already_done]
|
||||
if already_done:
|
||||
log.info(f" DimA: {len(dim_a_opts)} sizes ({len(dim_a_opts) - len(remaining)} already scraped, {len(remaining)} remaining)")
|
||||
else:
|
||||
log.info(f" DimA: {len(dim_a_opts)} sizes")
|
||||
|
||||
# All DimA values already scraped — combo is complete
|
||||
if not remaining:
|
||||
return []
|
||||
|
||||
for a_val, a_text in remaining:
|
||||
# Select DimA → triggers postback → DimB/Length populate
|
||||
await page.select_option(f"#{ID['dim_a']}", a_val)
|
||||
await asyncio.sleep(DELAY)
|
||||
await wait_for_update(page)
|
||||
|
||||
# Check if DimB appeared (secondary dimension: thickness, wall, etc.)
|
||||
dim_b_opts = await get_select_options(page, ID["dim_b"])
|
||||
if dim_b_opts:
|
||||
for b_val, b_text in dim_b_opts:
|
||||
await page.select_option(f"#{ID['dim_b']}", b_val)
|
||||
await asyncio.sleep(DELAY)
|
||||
await wait_for_update(page)
|
||||
|
||||
# Check for DimC (tertiary — rare)
|
||||
dim_c_opts = await get_select_options(page, ID["dim_c"])
|
||||
if dim_c_opts:
|
||||
for c_val, c_text in dim_c_opts:
|
||||
await page.select_option(f"#{ID['dim_c']}", c_val)
|
||||
await asyncio.sleep(DELAY)
|
||||
await wait_for_update(page)
|
||||
|
||||
lengths = await get_select_options(page, ID["dim_length"])
|
||||
for l_val, l_text in lengths:
|
||||
item = _make_item(
|
||||
grade, shape_mapped,
|
||||
a_val, a_text, b_val, b_text, c_val, c_text,
|
||||
l_text,
|
||||
)
|
||||
items.append(item)
|
||||
if on_item:
|
||||
on_item(item)
|
||||
else:
|
||||
# No DimC — read lengths
|
||||
lengths = await get_select_options(page, ID["dim_length"])
|
||||
for l_val, l_text in lengths:
|
||||
item = _make_item(
|
||||
grade, shape_mapped,
|
||||
a_val, a_text, b_val, b_text, None, None,
|
||||
l_text,
|
||||
)
|
||||
items.append(item)
|
||||
if on_item:
|
||||
on_item(item)
|
||||
else:
|
||||
# No DimB — just DimA + Length
|
||||
lengths = await get_select_options(page, ID["dim_length"])
|
||||
for l_val, l_text in lengths:
|
||||
item = _make_item(
|
||||
grade, shape_mapped,
|
||||
a_val, a_text, None, None, None, None,
|
||||
l_text,
|
||||
)
|
||||
items.append(item)
|
||||
if on_item:
|
||||
on_item(item)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _make_item(grade, shape, a_val, a_text, b_val, b_text, c_val, c_text, l_text):
|
||||
"""Build a raw item dict from dimension selections."""
|
||||
return {
|
||||
"grade": grade,
|
||||
"shape": shape,
|
||||
"dim_a_val": a_val, # decimal string like ".500"
|
||||
"dim_a_text": a_text, # fraction string like "1/2"
|
||||
"dim_b_val": b_val,
|
||||
"dim_b_text": b_text,
|
||||
"dim_c_val": c_val,
|
||||
"dim_c_text": c_text,
|
||||
"length_text": l_text,
|
||||
"length_inches": parse_length_to_inches(l_text),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Output — build catalog JSON
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def build_size_and_dims(shape: str, item: dict):
|
||||
"""Return (size_string, dimensions_dict) for a catalog material entry.
|
||||
|
||||
Uses the decimal values from dropdown option values for precision,
|
||||
and fraction text from dropdown option text for display.
|
||||
"""
|
||||
# Use the numeric value from the dropdown (e.g. ".500") for precision
|
||||
a = float(item["dim_a_val"]) if item.get("dim_a_val") else None
|
||||
b = float(item["dim_b_val"]) if item.get("dim_b_val") else None
|
||||
c = float(item["dim_c_val"]) if item.get("dim_c_val") else None
|
||||
|
||||
a_txt = normalize_dim_text(item.get("dim_a_text") or "")
|
||||
b_txt = normalize_dim_text(item.get("dim_b_text") or "")
|
||||
c_txt = normalize_dim_text(item.get("dim_c_text") or "")
|
||||
|
||||
if shape == "RoundBar" and a is not None:
|
||||
return f'{a_txt}"', {"diameter": round(a, 4)}
|
||||
|
||||
if shape == "FlatBar":
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" x {b_txt}"',
|
||||
{"width": round(a, 4), "thickness": round(b, 4)})
|
||||
if a is not None:
|
||||
return f'{a_txt}"', {"width": round(a, 4), "thickness": 0}
|
||||
|
||||
if shape == "SquareBar" and a is not None:
|
||||
return f'{a_txt}"', {"sideLength": round(a, 4)}
|
||||
|
||||
if shape == "Angle":
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" x {a_txt}" x {b_txt}"',
|
||||
{"leg1": round(a, 4), "leg2": round(a, 4), "thickness": round(b, 4)})
|
||||
if a is not None:
|
||||
return f'{a_txt}"', {"leg1": round(a, 4), "leg2": round(a, 4), "thickness": 0}
|
||||
|
||||
if shape == "Channel":
|
||||
# Channels may use DimA for combined designation or height
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" x {b_txt}"',
|
||||
{"height": round(a, 4), "flange": round(b, 4), "web": 0})
|
||||
if a is not None:
|
||||
return a_txt, {"height": round(a, 4), "flange": 0, "web": 0}
|
||||
|
||||
if shape == "IBeam":
|
||||
# DimA might be the W-designation, DimB the weight/ft
|
||||
if a is not None and b is not None:
|
||||
return (f"W{int(a)} x {b}",
|
||||
{"height": round(a, 4), "weightPerFoot": round(b, 4)})
|
||||
if a is not None:
|
||||
return f"W{int(a)}", {"height": round(a, 4), "weightPerFoot": 0}
|
||||
|
||||
if shape == "SquareTube":
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" x {b_txt}" wall',
|
||||
{"sideLength": round(a, 4), "wall": round(b, 4)})
|
||||
if a is not None:
|
||||
return f'{a_txt}"', {"sideLength": round(a, 4), "wall": 0}
|
||||
|
||||
if shape == "RectangularTube":
|
||||
if a is not None and b is not None and c is not None:
|
||||
return (f'{a_txt}" x {b_txt}" x {c_txt}" wall',
|
||||
{"width": round(a, 4), "height": round(b, 4), "wall": round(c, 4)})
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" x {b_txt}"',
|
||||
{"width": round(a, 4), "height": round(b, 4), "wall": 0})
|
||||
|
||||
if shape == "RoundTube":
|
||||
if a is not None and b is not None:
|
||||
return (f'{a_txt}" OD x {b_txt}" wall',
|
||||
{"outerDiameter": round(a, 4), "wall": round(b, 4)})
|
||||
if a is not None:
|
||||
return f'{a_txt}" OD', {"outerDiameter": round(a, 4), "wall": 0}
|
||||
|
||||
if shape == "Pipe":
|
||||
sched = b_txt or c_txt or "40"
|
||||
if a is not None:
|
||||
return (f'{a_txt}" NPS Sch {sched}',
|
||||
{"nominalSize": round(a, 4), "schedule": sched})
|
||||
|
||||
# Fallback
|
||||
return a_txt or "", {}
|
||||
|
||||
|
||||
SHAPE_GROUP_KEY = {
|
||||
"Angle": "angles",
|
||||
"Channel": "channels",
|
||||
"FlatBar": "flatBars",
|
||||
"IBeam": "iBeams",
|
||||
"Pipe": "pipes",
|
||||
"RectangularTube": "rectangularTubes",
|
||||
"RoundBar": "roundBars",
|
||||
"RoundTube": "roundTubes",
|
||||
"SquareBar": "squareBars",
|
||||
"SquareTube": "squareTubes",
|
||||
}
|
||||
|
||||
|
||||
def build_catalog(scraped: list[dict]) -> dict:
|
||||
"""Assemble the final catalog JSON from scraped item dicts."""
|
||||
materials: dict[tuple, dict] = {}
|
||||
|
||||
for item in scraped:
|
||||
shape = item.get("shape", "")
|
||||
grade = item.get("grade", "")
|
||||
if not shape or not grade:
|
||||
continue
|
||||
|
||||
size_str, dims = build_size_and_dims(shape, item)
|
||||
key = (shape, grade, size_str)
|
||||
|
||||
if key not in materials:
|
||||
mat = {
|
||||
"type": "Steel",
|
||||
"grade": grade,
|
||||
"size": size_str,
|
||||
"stockItems": [],
|
||||
}
|
||||
mat.update(dims)
|
||||
materials[key] = mat
|
||||
|
||||
length = item.get("length_inches")
|
||||
if length and length > 0:
|
||||
existing = {si["lengthInches"] for si in materials[key]["stockItems"]}
|
||||
if round(length, 4) not in existing:
|
||||
materials[key]["stockItems"].append({
|
||||
"lengthInches": round(length, 4),
|
||||
"quantityOnHand": 0,
|
||||
"supplierOfferings": [{
|
||||
"supplierName": "Alro Steel",
|
||||
"partNumber": "",
|
||||
"supplierDescription": "",
|
||||
}],
|
||||
})
|
||||
|
||||
# Group by shape key
|
||||
grouped: dict[str, list] = {v: [] for v in SHAPE_GROUP_KEY.values()}
|
||||
for (shape, _, _), mat in sorted(materials.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])):
|
||||
group_key = SHAPE_GROUP_KEY.get(shape)
|
||||
if group_key:
|
||||
grouped[group_key].append(mat)
|
||||
|
||||
return {
|
||||
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"suppliers": [{"name": "Alro Steel"}],
|
||||
"cuttingTools": [
|
||||
{"name": "Bandsaw", "kerfInches": 0.0625, "isDefault": True},
|
||||
{"name": "Chop Saw", "kerfInches": 0.125, "isDefault": False},
|
||||
{"name": "Cold Cut Saw", "kerfInches": 0.0625, "isDefault": False},
|
||||
{"name": "Hacksaw", "kerfInches": 0.0625, "isDefault": False},
|
||||
],
|
||||
"materials": grouped,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Progress management
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def load_progress() -> dict:
|
||||
if PROGRESS_PATH.exists():
|
||||
return json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
|
||||
return {"completed": [], "items": []}
|
||||
|
||||
|
||||
def save_progress(progress: dict):
|
||||
PROGRESS_PATH.write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def main():
|
||||
discover = "--discover" in sys.argv
|
||||
fresh = "--fresh" in sys.argv
|
||||
all_grades = "--all-grades" in sys.argv
|
||||
|
||||
progress = {"completed": [], "items": []} if fresh else load_progress()
|
||||
all_items: list[dict] = progress.get("items", [])
|
||||
done_keys: set[tuple] = {tuple(k) for k in progress.get("completed", [])}
|
||||
|
||||
# Build index of saved DimA values per (grade, shape) for partial resume
|
||||
saved_dim_a: dict[tuple[str, str], set[str]] = {}
|
||||
if all_items and not fresh:
|
||||
for item in all_items:
|
||||
key = (item.get("grade", ""), item.get("shape", ""))
|
||||
saved_dim_a.setdefault(key, set()).add(item.get("dim_a_val", ""))
|
||||
|
||||
log.info("Alro Steel SmartGrid Scraper")
|
||||
if all_grades:
|
||||
log.info(" Mode: ALL grades")
|
||||
else:
|
||||
log.info(f" Filtering to {len(GRADE_FILTER)} grades: {', '.join(sorted(GRADE_FILTER))}")
|
||||
if fresh:
|
||||
log.info(" Fresh start — ignoring saved progress")
|
||||
elif done_keys:
|
||||
log.info(f" Resuming: {len(done_keys)} combos done, {len(all_items)} items saved")
|
||||
if discover:
|
||||
log.info(" Discovery mode — will scrape first item then stop")
|
||||
|
||||
async with Stealth().use_async(async_playwright()) as pw:
|
||||
browser = await pw.chromium.launch(headless=False)
|
||||
ctx = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 900},
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
locale="en-US",
|
||||
timezone_id="America/Indiana/Indianapolis",
|
||||
)
|
||||
page = await ctx.new_page()
|
||||
|
||||
log.info(f"Navigating to SmartGrid …")
|
||||
await page.goto(BASE_URL, wait_until="networkidle", timeout=30_000)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if not await page.query_selector(f"#{ID['main_grid']}"):
|
||||
log.error("Main grid not found! Saving screenshot.")
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
await page.screenshot(path=str(SCREENSHOTS_DIR / "error_no_grid.png"))
|
||||
await browser.close()
|
||||
return
|
||||
|
||||
log.info("Main grid loaded")
|
||||
total_scraped = 0
|
||||
first_item = True
|
||||
|
||||
for category in CATEGORIES:
|
||||
log.info(f"\n{'=' * 60}")
|
||||
log.info(f" Category: {category}")
|
||||
log.info(f"{'=' * 60}")
|
||||
|
||||
if not await click_category(page, category):
|
||||
continue
|
||||
await asyncio.sleep(DELAY)
|
||||
|
||||
combos = await scrape_popup_grid(page)
|
||||
|
||||
for grade_name, grade_id, shape_name, row_idx, has_btn in combos:
|
||||
if not has_btn:
|
||||
continue
|
||||
|
||||
# Grade filter
|
||||
if not all_grades and grade_name not in GRADE_FILTER:
|
||||
continue
|
||||
|
||||
shape_upper = shape_name.upper().strip()
|
||||
shape_mapped = SHAPE_MAP.get(shape_upper)
|
||||
if shape_mapped is None:
|
||||
log.info(f" Skip unmapped shape: {shape_name}")
|
||||
continue
|
||||
|
||||
combo_key = (category, grade_name, shape_name)
|
||||
if combo_key in done_keys:
|
||||
log.info(f" Skip (done): {grade_name} / {shape_name}")
|
||||
continue
|
||||
|
||||
log.info(f"\n -- {grade_name} / {shape_name} -> {shape_mapped} --")
|
||||
|
||||
if not await click_shape(page, shape_name, row_idx):
|
||||
await click_back(page)
|
||||
await asyncio.sleep(DELAY)
|
||||
continue
|
||||
|
||||
await asyncio.sleep(DELAY)
|
||||
|
||||
combo_count = 0
|
||||
def on_item_discovered(item):
|
||||
nonlocal total_scraped, combo_count
|
||||
all_items.append(item)
|
||||
total_scraped += 1
|
||||
combo_count += 1
|
||||
progress["items"] = all_items
|
||||
save_progress(progress)
|
||||
|
||||
# Pass already-scraped DimA values so partial combos resume correctly
|
||||
already = saved_dim_a.get((grade_name, shape_mapped), set())
|
||||
|
||||
items = await scrape_dims_panel(
|
||||
page, grade_name, shape_name, shape_mapped,
|
||||
save_discovery=first_item or discover,
|
||||
on_item=on_item_discovered,
|
||||
scraped_dim_a=already,
|
||||
)
|
||||
first_item = False
|
||||
|
||||
log.info(f" -> {combo_count} items (total {total_scraped})")
|
||||
|
||||
done_keys.add(combo_key)
|
||||
progress["completed"] = [list(k) for k in done_keys]
|
||||
save_progress(progress)
|
||||
|
||||
await click_back(page)
|
||||
await asyncio.sleep(DELAY)
|
||||
|
||||
if discover:
|
||||
log.info("\nDiscovery done. Check: scripts/AlroCatalog/screenshots/")
|
||||
await browser.close()
|
||||
return
|
||||
|
||||
await close_popup(page)
|
||||
await asyncio.sleep(DELAY)
|
||||
|
||||
await browser.close()
|
||||
|
||||
# ── Build output ──
|
||||
log.info(f"\n{'=' * 60}")
|
||||
log.info(f"Building catalog from {len(all_items)} items …")
|
||||
catalog = build_catalog(all_items)
|
||||
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT_PATH.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
log.info(f"Written: {OUTPUT_PATH}")
|
||||
total_mats = sum(len(v) for v in catalog["materials"].values())
|
||||
total_stock = sum(len(m["stockItems"]) for v in catalog["materials"].values() for m in v)
|
||||
log.info(f"Materials: {total_mats}")
|
||||
log.info(f"Stock items: {total_stock}")
|
||||
for shape_key, mats in sorted(catalog["materials"].items()):
|
||||
if mats:
|
||||
log.info(f" {shape_key}: {len(mats)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Focused regression checks for split part-number and length cut badges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class CutBadgeFormatTests(unittest.TestCase):
|
||||
def test_named_cut_badge_renders_part_number_and_length_in_separate_sections(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
cuts_cell_start = markup.index("@foreach (var item in entry.Bin.Items)", results_start)
|
||||
cut_rows_start = markup.rfind("<td>", results_start, cuts_cell_start)
|
||||
cut_rows = markup[cut_rows_start:markup.index("</td>", cuts_cell_start)]
|
||||
|
||||
self.assertIn('class="badge me-1 mb-1 fs-6 cut-part-badge"', cut_rows)
|
||||
self.assertIn('class="cut-part-badge-name"', cut_rows)
|
||||
self.assertIn('class="cut-part-badge-length"', cut_rows)
|
||||
self.assertIn('@item.Name', cut_rows)
|
||||
self.assertIn('@FormatResultLength(item.Length)', cut_rows)
|
||||
self.assertNotIn('$"{item.Name} (', cut_rows)
|
||||
|
||||
def test_length_section_has_a_darker_background_than_the_part_number_section(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
|
||||
self.assertIn('.cut-part-badge {', css)
|
||||
self.assertIn('.cut-part-badge-name {', css)
|
||||
self.assertIn('.cut-part-badge-length {', css)
|
||||
self.assertIn('background-color: #4c6680;', css)
|
||||
|
||||
def test_printed_cut_badges_keep_part_numbers_and_separators_legible(self) -> None:
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
print_styles = report_css[report_css.index("@media print {"):]
|
||||
|
||||
self.assertIn(".cut-part-badge {", print_styles)
|
||||
self.assertIn("color: #000 !important;", print_styles)
|
||||
self.assertIn(".cut-part-badge-divider {", print_styles)
|
||||
self.assertIn("border-left: 1px solid #000 !important;", print_styles)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Regression checks for compact CutList identifiers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_SERVICE = REPO_ROOT / "CutList.Web/Services/JobService.cs"
|
||||
UNPAD_MIGRATION = REPO_ROOT / "CutList.Web/Migrations/20260802212500_RemoveJobNumberPadding.cs"
|
||||
|
||||
|
||||
class JobNumberFormatTests(unittest.TestCase):
|
||||
def test_new_jobs_use_the_database_id_without_zero_padding(self) -> None:
|
||||
service = JOB_SERVICE.read_text()
|
||||
|
||||
self.assertIn('job.JobNumber = $"JOB-{job.Id}";', service)
|
||||
self.assertNotIn("D5", service)
|
||||
self.assertNotIn("MaxAsync() as string", service)
|
||||
|
||||
def test_existing_job_numbers_are_converted_to_their_database_ids(self) -> None:
|
||||
migration = UNPAD_MIGRATION.read_text()
|
||||
|
||||
self.assertIn("SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))", migration)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Focused regression checks for job-level actions on the Results tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||
|
||||
|
||||
class JobResultsActionsTests(unittest.TestCase):
|
||||
def test_lock_job_is_in_the_results_action_row_before_result_cards(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
result_cards_start = markup.index("@if (packResult != null && summary != null)", results_start)
|
||||
action_row = markup[results_start:result_cards_start]
|
||||
|
||||
self.assertIn('@onclick="PrintReport"', action_row)
|
||||
self.assertIn('@onclick="LockJob"', action_row)
|
||||
self.assertIn("Lock Job", action_row)
|
||||
|
||||
def test_print_report_hides_job_lock_status(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
|
||||
self.assertIn('class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only"', markup)
|
||||
self.assertIn('<span class="badge bg-success print-screen-only">', markup)
|
||||
self.assertIn(".print-screen-only", report_css)
|
||||
self.assertIn("display: none !important;", report_css)
|
||||
|
||||
def test_print_report_forces_backgrounds_to_plain_white(self) -> None:
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
print_styles = report_css[report_css.index("@media print {"):]
|
||||
|
||||
self.assertIn("*::before", print_styles)
|
||||
self.assertIn("background: transparent !important;", print_styles)
|
||||
self.assertIn("box-shadow: none !important;", print_styles)
|
||||
|
||||
def test_print_report_shows_the_selected_cutting_method_and_kerf(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
summary_start = markup.index('<div class="row mb-4 print-summary">', results_start)
|
||||
material_list_start = markup.index('<!-- Material List:', summary_start)
|
||||
report_summary = markup[summary_start:material_list_start]
|
||||
|
||||
self.assertIn('class="print-cut-method"', report_summary)
|
||||
self.assertIn("<strong>Cut Method:</strong>", report_summary)
|
||||
self.assertIn("@job.CuttingTool.Name", report_summary)
|
||||
self.assertIn("Kerf:", report_summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Regression checks for the CutList navigation brand on its light header."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NAV_MENU = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor"
|
||||
NAV_MENU_CSS = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor.css"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
APP_SHELL = REPO_ROOT / "CutList.Web/Components/App.razor"
|
||||
|
||||
|
||||
def selector_color(css: str, selector: str) -> str:
|
||||
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css)
|
||||
if match is None:
|
||||
raise AssertionError(f"Missing CSS selector: {selector}")
|
||||
|
||||
color = re.search(r"\bcolor:\s*(#[0-9a-fA-F]{6})", match.group(1))
|
||||
if color is None:
|
||||
raise AssertionError(f"Missing hex color for selector: {selector}")
|
||||
return color.group(1)
|
||||
|
||||
|
||||
def contrast_ratio(foreground: str, background: str) -> float:
|
||||
def luminance(color: str) -> float:
|
||||
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
class NavMenuBrandContrastTests(unittest.TestCase):
|
||||
def test_list_word_has_accessible_contrast_against_light_top_row(self) -> None:
|
||||
list_color = selector_color(NAV_MENU_CSS.read_text(), ".navbar-brand .brand-list")
|
||||
top_row_color = selector_color(APP_CSS.read_text(), ".top-row")
|
||||
|
||||
self.assertGreaterEqual(contrast_ratio(list_color, top_row_color), 4.5)
|
||||
|
||||
def test_brand_keeps_cut_and_list_as_adjacent_words(self) -> None:
|
||||
markup = NAV_MENU.read_text()
|
||||
self.assertIn('<span class="brand-cut">Cut</span><span class="brand-list">List</span>', markup)
|
||||
|
||||
def test_visual_system_uses_the_industrial_blue_accent(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn("--accent: #2b75a8;", css)
|
||||
self.assertIn("--navy: #203747;", css)
|
||||
self.assertNotIn("--orange:", css)
|
||||
|
||||
def test_desktop_content_padding_does_not_push_the_nav_brand_right(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
self.assertNotIn(".top-row, article {", css)
|
||||
|
||||
def test_nav_brand_is_left_aligned_with_the_navigation_items(self) -> None:
|
||||
css = NAV_MENU_CSS.read_text()
|
||||
self.assertIn(".top-row { justify-content: flex-start;", css)
|
||||
|
||||
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
|
||||
shell = APP_SHELL.read_text()
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn("family=IBM+Plex+Sans", shell)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', css)
|
||||
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
|
||||
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
|
||||
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Focused regression checks for the data-backed CutList overview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class OverviewDashboardTests(unittest.TestCase):
|
||||
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn("GetSnapshotAsync", source)
|
||||
self.assertIn("context.Jobs", source)
|
||||
self.assertIn("context.JobStocks", source)
|
||||
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
|
||||
|
||||
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||
self.assertIn("Recently created jobs", markup)
|
||||
self.assertIn("Frequently specified stock", markup)
|
||||
self.assertIn("Based on stock configured on jobs", markup)
|
||||
self.assertIn("overview.RecentJobs", markup)
|
||||
self.assertIn("overview.FrequentStock", markup)
|
||||
|
||||
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn(
|
||||
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
|
||||
source,
|
||||
)
|
||||
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
|
||||
|
||||
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn(".overview-grid", css)
|
||||
self.assertIn(".overview-stock-list", css)
|
||||
self.assertIn(".overview-recent-jobs", css)
|
||||
|
||||
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("loadError", markup)
|
||||
self.assertIn("Overview data is unavailable", markup)
|
||||
self.assertIn("catch (Exception ex)", markup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user