Compare commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:16:37 -04:00
aj 9b1564451d Remove unused workspace-header from MainLayout
Build CutList image / build-and-push (push) Successful in 22s
It was static markup (fixed "Cut planning workspace" / "Ready to plan"
text) that never varied by page or state, so it was pure dead weight.
2026-08-03 07:53:15 -04:00
aj 5409479e00 fix(web): make printed cut instructions legible
Build CutList image / build-and-push (push) Successful in 19s
Keep part numbers and their dividers visible in monochrome print output, and include the selected cutting tool and kerf on the printed report.
2026-08-03 02:18:45 +00:00
aj 25af9e3ba8 fix(web): keep printed cut-list rows together
Build CutList image / build-and-push (push) Successful in 22s
2026-08-03 01:51:11 +00:00
aj 3ccaddef0c [verified] feat: add configurable results length units
Build CutList image / build-and-push (push) Successful in 31s
Default cut results to total-inch mixed fractions, allow switching to feet and inches, and separate part names from lengths visually.
2026-08-03 01:31:09 +00:00
aj 79bf56afd8 docs: clarify job lock workflow 2026-08-03 01:09:58 +00:00
aj 96f5824d12 fix(web): simplify printed cut reports
Build CutList image / build-and-push (push) Successful in 19s
2026-08-03 01:08:43 +00:00
aj db054aa6dc fix(web): hide job lock status from printed reports
Build CutList image / build-and-push (push) Successful in 21s
2026-08-03 00:26:30 +00:00
aj 8e2027d81b feat(web): split cut badge lengths
Build CutList image / build-and-push (push) Successful in 21s
2026-08-03 00:09:44 +00:00
aj 57c7aa5f9f feat(web): use compact cut list identifiers
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 21:33:29 +00:00
aj 34c9b77501 fix(web): place lock action in results toolbar
Build CutList image / build-and-push (push) Successful in 20s
2026-08-02 21:19:36 +00:00
aj 203ea0c69d style(web): compact CutList typography
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:57:17 +00:00
aj 5cbd06711c fix(web): load overview stock summary with scalar projection
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:24:04 +00:00
aj f3b911f37c feat(web): add data-backed planning overview
Build CutList image / build-and-push (push) Successful in 19s
2026-08-02 20:18:07 +00:00
aj 1f04c5343a feat(web): refresh CutList visual system
Build CutList image / build-and-push (push) Successful in 24s
2026-08-02 19:57:57 +00:00
aj 3fbcb05d4d fix(web): restore CutList brand contrast 2026-08-02 19:45:32 +00:00
aj 4f21d536f8 fix(web): label required lengths as material list
Build CutList image / build-and-push (push) Successful in 27s
2026-08-02 18:32:45 +00:00
aj c1409bbdfb fix(web): reduce job title heading size
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 14:32:39 +00:00
aj 7882ed7669 fix(web): keep table action buttons inline
Build CutList image / build-and-push (push) Successful in 18s
2026-08-02 14:26:56 +00:00
aj af37789dcd Remove Alro catalog scraper and seed data
Build CutList image / build-and-push (push) Successful in 20s
Standard available lengths are now sourced from Oneal instead, so the
Alro scraping workflow and its generated seed data are no longer needed.
2026-08-02 10:00:44 -04:00
aj ebe725579b Improve CutList brand contrast
Build CutList image / build-and-push (push) Successful in 20s
2026-08-02 09:53:25 -04:00
aj 05179aa445 Redesign CutList workspace UI
Build CutList image / build-and-push (push) Successful in 25s
2026-08-02 09:12:04 -04:00
ajandClaude Sonnet 5 ec16dd2c9b fix: polish unified add-stock modal (Edit.razor)
Build CutList image / build-and-push (push) Successful in 24s
Apply four low-risk fixes from final code review of the consolidated
add-stock modal: reset newStock on modal close, dedupe stock-item
loading through the existing helper, correct stale empty-state copy
about inventory fallback, and remove a stray blank line left from the
old form deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:32:51 -04:00
ajandClaude Sonnet 5 0c5ecf716a fix: prefill Stock Length dropdown when editing inventory-sourced stock
Editing an inventory-sourced job stock row opened the Edit Stock modal
with the Stock Length dropdown blank instead of showing the row's
current length. EditStock() correctly set newStock.StockItemId from
the row, but then called the fire-and-forget OnStockMaterialChanged(),
which (a) unconditionally reset StockItemId to null, and (b) never
triggered a re-render since its Task wasn't awaited by the event
handler pipeline. Made EditStock async and await a dedicated loader
that populates availableStockItems without touching StockItemId, so
Blazor re-renders once the candidate lengths arrive.

Found during Task 2 manual verification of the unified Add Stock
modal (Step 6).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:23:49 -04:00
aj c66099481b docs: add implementation plan for unified add/edit stock modal 2026-08-01 19:08:28 -04:00
aj 9020ba80c1 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.
2026-08-01 19:02:27 -04:00
aj 2cb9c5ec8d docs: add design spec for unified add/edit stock modal
Job Edit page's Stock tab currently has three inconsistent entry points
(bulk import modal, inline custom-length form, inline edit forms) for
what should be a single add/edit stock flow.
2026-08-01 17:52:49 -04:00
ajandClaude Sonnet 5 232b86f15b docs: add implementation plan for removing inventory quantity tracking
Companion to the design spec, kept for reference alongside the executed work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 11:44:50 -04:00
aj ec52834cd4 fix: correct stale min attribute and MCP wording from inventory removal
Build CutList image / build-and-push (push) Successful in 23s
Three small leftovers from Task 7 / the inventory-quantity-tracking
removal that a final review caught:

- Edit.razor's custom-stock quantity input still had min="1" even
  though its own helper text says "Use -1 for unlimited", validation
  already accepts -1, and the add-form defaults Quantity to -1 - so
  the field rendered pre-populated with a value the browser's native
  min validation flagged as invalid. Matches the min="-1" fix already
  applied to the catalog-stock input in Task 7.
- optimize_job's MCP tool description still claimed the optimizer
  falls back to "available inventory" when no stock is configured -
  that auto-discovery fallback was removed in Task 3. An LLM reads
  this description as ground truth, so stale wording here actively
  misleads tool use. Also tightened add_job_stock's stockItemId/
  isCustomLength wording, which still said "inventory" for what is
  now just the stock catalog.
- CLAUDE.md's CutListPackingService summary said InStockBins comes
  "from inventory", nudging future readers back toward the
  auto-discovery mental model this plan removed.
2026-08-01 11:39:35 -04:00
aj d8656cc454 fix: classify unlimited catalog-sourced job stock as in-stock
Task 7 extended JobStock.Quantity = -1 (unlimited) to catalog-sourced
rows, but the post-pack classification step in CutListPackingService
only ever treated a catalog-sourced bin as "in stock" when its tracked
quantity was a positive finite number. An unlimited catalog row fell
through to "to be purchased" even though IsInStock=true just means the
bin is catalog-sourced, not a quantity check.

This made the classification effectively unreachable for the most
common path users take to stock a job: the "Import from Inventory"
modal defaults every candidate's quantity to -1, so every resulting
bin was mislabeled "to be purchased" on the Results tab, and the
"everything is available in stock" message could never appear for
jobs stocked that way.

Track unlimited catalog-sourced lengths in a separate set and check it
first; finite catalog quantities keep the existing decrementing-counter
behavior, and custom-length stock (any quantity) is unaffected.
2026-08-01 11:39:28 -04:00
aj 0333942cb3 docs: update CLAUDE.md for inventory quantity tracking removal 2026-08-01 11:26:04 -04:00
aj 49a539d7e5 feat: allow unlimited quantity for catalog-sourced job stock
Custom-length job stock already supported -1 (unlimited); catalog-
sourced stock only allowed >= 1. Closes that inconsistency per
docs/superpowers/specs/2026-08-01-remove-inventory-quantity-tracking-design.md.
Also updates the Results tab's unplaced-items message now that
insufficient configured quantity is a normal cause, not an edge case.
2026-08-01 11:12:12 -04:00
aj 6bdbdf4969 feat: drop QuantityOnHand column and StockTransactions table
Final step of removing inventory quantity tracking - see
docs/superpowers/specs/2026-08-01-remove-inventory-quantity-tracking-design.md.
Destructive to any existing on-hand/transaction data; confirmed
acceptable since nothing read it automatically.
2026-08-01 11:04:11 -04:00
aj 383fa19cf2 refactor: drop QuantityOnHand from CutList.Mcp inventory tools
Matches the REST API's StockItemDto/CreateStockItemDto shape after
inventory quantity tracking removal. Republished to
~/.claude/mcp/CutList.Mcp/.
2026-08-01 10:56:17 -04:00
aj 843ba24788 refactor: drop QuantityOnHand from catalog import/export
Part of removing inventory quantity tracking; existing seed JSON keeps
the field on disk but it's now ignored on import.
2026-08-01 10:53:09 -04:00
aj 41283987b9 refactor: remove packing auto-discovery fallback
The fallback silently pulled from StockItem.QuantityOnHand and always
added an extra unlimited bin on top when a job had no stock configured
- i.e. it assumed unlimited purchasing. Per
docs/superpowers/specs/2026-08-01-remove-inventory-quantity-tracking-design.md,
a job's available stock must now be exactly what's explicitly entered.
2026-08-01 10:48:56 -04:00
aj 41dda11062 refactor: remove stock-transaction service methods, endpoints, and DTOs
No job workflow ever called these (receive/use/adjust/scrap/recalculate)
- inventory quantity tracking is being removed per
docs/superpowers/specs/2026-08-01-remove-inventory-quantity-tracking-design.md.
2026-08-01 10:43:01 -04:00
aj f71cf8ab0a fix: remove debug TEST123 text from Stock Items heading 2026-08-01 10:37:00 -04:00
aj fd6e8d632c refactor: remove QuantityOnHand display from stock and job UI
First step of removing inventory quantity tracking (see
docs/superpowers/specs/2026-08-01-remove-inventory-quantity-tracking-design.md).
UI no longer shows on-hand counts or offers transaction entry; the
underlying field/service methods are removed in follow-up commits.
2026-08-01 10:32:12 -04:00
ajandClaude Sonnet 5 69c993fcb2 docs: add design spec for removing inventory quantity tracking
Users need to enter parts/stock and get results without a disconnected
inventory system to manage; StockItem.QuantityOnHand and StockTransaction
today aren't read or written by the job workflow anywhere except an
auto-discovery fallback that silently assumes unlimited purchasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 10:12:56 -04:00
52 changed files with 4265 additions and 3027 deletions
+4 -5
View File
@@ -244,13 +244,12 @@ ModelManifest.xml
# FAKE - F# Make # FAKE - F# Make
.fake/ .fake/
# AlroCatalog scraper output
scripts/AlroCatalog/__pycache__/
scripts/AlroCatalog/pdfs/
scripts/AlroCatalog/screenshots/
# Superpowers subagent-driven-development scratch workspace # Superpowers subagent-driven-development scratch workspace
.superpowers/ .superpowers/
# Playwright MCP browser session logs/traces # Playwright MCP browser session logs/traces
.playwright-mcp/ .playwright-mcp/
# Python bytecode cache (tests/)
__pycache__/
*.pyc
+20 -17
View File
@@ -12,9 +12,9 @@ The solution contains four projects:
| Project | Framework | Purpose | | Project | Framework | Purpose |
|---------|-----------|---------| |---------|-----------|---------|
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) | | **CutList** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) | | **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server | | **CutList.Web** | .NET 10.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude | | **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp) **Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
@@ -97,6 +97,14 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`. **Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
**Table actions**: Every row-level action cell uses the shared `table-actions` class from `wwwroot/css/app.css`. It is an `inline-flex` non-wrapping control group, so Edit/Delete/Copy buttons remain side by side and do not increase table-row height.
**Job title**: The job-editor page uses the `job-title` class to keep long job names at a compact, readable heading size without changing the larger dashboard hero typography.
**Overview**: The root page uses `OverviewService` to show recently created jobs, headline planning counts, and the five stock configurations most frequently specified across job stock. The stock ranking is a planning-demand signal (distinct jobs configured for a material/length), not a count of on-hand inventory.
**Material list semantics**: The Results tab labels lengths not covered by the job's configured stock as a **Material List**, not a purchase list. It identifies required material; purchasing remains a separate decision outside the cut-list result.
### CutList.Mcp — MCP Server ### CutList.Mcp — MCP Server
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport. Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
@@ -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`. 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 ### StockItem
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive` - `MaterialId`, `LengthInches` (decimal), `IsActive`
- **Unique constraint**: (MaterialId, LengthInches) - **Unique constraint**: (MaterialId, LengthInches)
- **Relationships**: `Material`, `Transactions` (1:many StockTransaction) - **Relationships**: `Material`
- No quantity tracking — a StockItem represents a length of material you can cut from, not a counted inventory record
### StockTransaction
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
- Optional: `JobId`
### CuttingTool ### CuttingTool
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive` - `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
@@ -154,8 +159,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
### StockItemService ### StockItemService
- CRUD with soft delete - CRUD with soft delete
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
### JobService ### JobService
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync` - 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 ### CutListPackingService
- `PackAsync(parts, kerfInches, jobStock?)` — runs optimization per material group - `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 % - `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.) - `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 ### CatalogService
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling - `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` / `scripts/AlroCatalog` data-loading workflows - Backs the `CatalogController` REST endpoint and the `scripts/ExportData` data-loading workflow
## CutList.Web Pages ## CutList.Web Pages
@@ -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 | | `/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` | Materials/Index | Material list with MaterialFilter, pagination |
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) | | `/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 | | `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
| `/tools` | Tools/Index | Cutting tools CRUD | | `/tools` | Tools/Index | Cutting tools CRUD |
| `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) | | `/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 - **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns) - **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited - **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 - **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
- **Job lock flow** — Optimize job -> Lock Job (manual action, available whether or not purchases are needed) -> job becomes read-only until Unlock - **Job lock flow** — Optimize job -> review/print results -> Lock Job (manual action beside Print Report on the Results tab, available whether or not purchases are needed) -> job becomes read-only until Unlock
- **Printed cut badges** — Print styles intentionally remove color fills; cut badges therefore force black text and a black part-number/length divider so both remain legible on paper. The print-only tool line shows the selected cut method and kerf immediately below the summary.
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications - **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods - **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
- **Priority system** — Lower priority bins used first in packing algorithm - **Priority system** — Lower priority bins used first in packing algorithm
@@ -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) - `Deploy-CutListWeb.ps1` — publishes and installs CutList.Web as a Windows Service (see Build Commands above)
- `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`) - `ExportData/` — standalone console project that exercises `CutList.Web`'s data layer to import/export catalog seed data (e.g. `Data/SeedData/oneals-catalog.json`)
- `AlroCatalog/` — Python scraper (`scrape_alro.py`) for pulling material/size/grade data from the Alro Steel SmartGrid site into a JSON catalog for import; see `SCRAPE_PLAN.md` for scraper status and cascading-dropdown navigation notes. Not part of the .NET build.
## Key Files ## Key Files
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
using CutList.Core.Formatting;
using Xunit;
namespace CutList.Core.Tests;
public class ResultsLengthDisplayTests
{
[Theory]
[InlineData(150, "150\"")]
[InlineData(150.375, "150-3/8\"")]
public void FormatInches_uses_total_inches_without_converting_to_feet(double inches, string expected)
{
Assert.Equal(expected, ArchUnits.FormatInches(inches));
}
[Fact]
public void Results_markup_includes_unit_toggle_and_divider_between_part_and_length()
{
var sourcePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/Components/Pages/Jobs/Edit.razor"));
var markup = File.ReadAllText(sourcePath);
Assert.Contains("FormatResultLength", markup);
Assert.Contains("cut-part-badge-divider", markup);
Assert.Contains("Show feet + inches", markup);
}
[Fact]
public void Printed_cut_list_rows_are_kept_together()
{
var sourcePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/wwwroot/css/report.css"));
var css = File.ReadAllText(sourcePath);
Assert.Contains(".cutlist-material-card tbody tr", css);
Assert.Contains("break-inside: avoid", css);
Assert.Contains("page-break-inside: avoid", css);
}
}
+8
View File
@@ -80,5 +80,13 @@ namespace CutList.Core.Formatting
return $"{inches}\""; return $"{inches}\"";
} }
} }
/// <summary>
/// Formats a measurement as a mixed fraction of total inches without converting to feet.
/// </summary>
public static string FormatInches(double totalInches)
{
return $"{FormatHelper.ConvertToMixedFraction(totalInches)}\"";
}
} }
} }
+1 -3
View File
@@ -70,14 +70,13 @@ public class ApiClient
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? []; 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 var body = new
{ {
MaterialId = materialId, MaterialId = materialId,
Length = length, Length = length,
Name = name, Name = name,
QuantityOnHand = quantityOnHand,
Notes = notes Notes = notes
}; };
var response = await _http.PostAsJsonAsync("api/stock-items", body); var response = await _http.PostAsJsonAsync("api/stock-items", body);
@@ -360,7 +359,6 @@ public class ApiStockItemDto
public decimal LengthInches { get; set; } public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty; public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }
+4 -13
View File
@@ -179,7 +179,6 @@ public class InventoryTools
LengthInches = s.LengthInches, LengthInches = s.LengthInches,
LengthFormatted = s.LengthFormatted, LengthFormatted = s.LengthFormatted,
Name = s.Name, Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes, Notes = s.Notes,
IsActive = s.IsActive IsActive = s.IsActive
}).ToList() }).ToList()
@@ -194,14 +193,12 @@ public class InventoryTools
string length, string length,
[Description("Optional name/label for this stock item")] [Description("Optional name/label for this stock item")]
string? name = null, string? name = null,
[Description("Initial quantity on hand (default 0)")]
int quantityOnHand = 0,
[Description("Notes")] [Description("Notes")]
string? notes = null) string? notes = null)
{ {
try try
{ {
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, quantityOnHand, notes); var stockItem = await _api.CreateStockItemAsync(materialId, length, name, notes);
if (stockItem == null) if (stockItem == null)
return new StockItemResult { Success = false, Error = "Failed to create stock item" }; return new StockItemResult { Success = false, Error = "Failed to create stock item" };
@@ -217,7 +214,6 @@ public class InventoryTools
LengthInches = stockItem.LengthInches, LengthInches = stockItem.LengthInches,
LengthFormatted = stockItem.LengthFormatted, LengthFormatted = stockItem.LengthFormatted,
Name = stockItem.Name, Name = stockItem.Name,
QuantityOnHand = stockItem.QuantityOnHand,
Notes = stockItem.Notes, Notes = stockItem.Notes,
IsActive = stockItem.IsActive IsActive = stockItem.IsActive
} }
@@ -237,7 +233,7 @@ public class InventoryTools
#region Convenience #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( public async Task<AddStockResult> AddStock(
[Description("Material shape (e.g., 'Angle', 'FlatBar')")] [Description("Material shape (e.g., 'Angle', 'FlatBar')")]
string shape, string shape,
@@ -245,8 +241,6 @@ public class InventoryTools
string size, string size,
[Description("Stock length (e.g., '20'', '240')")] [Description("Stock length (e.g., '20'', '240')")]
string length, string length,
[Description("Quantity on hand (default 0)")]
int quantityOnHand = 0,
[Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")] [Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")]
string type = "Steel", string type = "Steel",
[Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")] [Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")]
@@ -315,7 +309,7 @@ public class InventoryTools
{ {
try try
{ {
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, quantityOnHand, null); stockItem = await _api.CreateStockItemAsync(material.Id, length, null, null);
stockItemCreated = true; stockItemCreated = true;
} }
catch (ApiConflictException) catch (ApiConflictException)
@@ -351,8 +345,7 @@ public class InventoryTools
MaterialCreated = materialCreated, MaterialCreated = materialCreated,
StockItemId = stockItem.Id, StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated, StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches), LengthFormatted = ArchUnits.FormatFromInches(lengthInches)
QuantityOnHand = stockItem.QuantityOnHand
}; };
} }
@@ -519,7 +512,6 @@ public class StockItemDto
public decimal LengthInches { get; set; } public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty; public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }
@@ -548,7 +540,6 @@ public class AddStockResult
public int StockItemId { get; set; } public int StockItemId { get; set; }
public bool StockItemCreated { get; set; } public bool StockItemCreated { get; set; }
public string LengthFormatted { get; set; } = string.Empty; public string LengthFormatted { get; set; } = string.Empty;
public int QuantityOnHand { get; set; }
} }
#endregion #endregion
+3 -3
View File
@@ -257,9 +257,9 @@ public class JobTools
string length, string length,
[Description("Quantity available (-1 for unlimited, default -1)")] [Description("Quantity available (-1 for unlimited, default -1)")]
int quantity = -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, 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, bool isCustomLength = false,
[Description("Priority - lower number = used first (default 10)")] [Description("Priority - lower number = used first (default 10)")]
int priority = 10) int priority = 10)
@@ -304,7 +304,7 @@ public class JobTools
#region Optimization #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( public async Task<OptimizeJobResult> OptimizeJob(
[Description("Job ID")] [Description("Job ID")]
int jobId, int jobId,
+3
View File
@@ -6,6 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" /> <base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/report.css" /> <link rel="stylesheet" href="css/report.css" />
@@ -6,7 +6,7 @@
</div> </div>
<main> <main>
<article class="content px-4"> <article class="content">
@Body @Body
</article> </article>
</main> </main>
+11 -8
View File
@@ -1,6 +1,7 @@
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row">
<div class="container-fluid"> <div class="brand-lockup">
<a class="navbar-brand" href="">CutList</a> <a class="brand-mark" href="" aria-label="CutList home"><i class="bi bi-scissors"></i></a>
<a class="navbar-brand" href=""><span class="brand-cut">Cut</span><span class="brand-list">List</span><small>SHOP FLOOR EDITION</small></a>
</div> </div>
</div> </div>
@@ -8,29 +9,31 @@
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> <div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column"> <nav class="flex-column">
<p class="nav-section-label">Workspace</p>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home <i class="bi bi-grid-1x2-fill" aria-hidden="true"></i> Overview
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="jobs"> <NavLink class="nav-link" href="jobs">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs <i class="bi bi-layers-fill" aria-hidden="true"></i> Jobs
</NavLink> </NavLink>
</div> </div>
<p class="nav-section-label">Library</p>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="materials"> <NavLink class="nav-link" href="materials">
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials <i class="bi bi-box-seam-fill" aria-hidden="true"></i> Materials
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="stock"> <NavLink class="nav-link" href="stock">
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items <i class="bi bi-rulers" aria-hidden="true"></i> Stock Items
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="tools"> <NavLink class="nav-link" href="tools">
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools <i class="bi bi-tools" aria-hidden="true"></i> Cutting Tools
</NavLink> </NavLink>
</div> </div>
</nav> </nav>
+20 -119
View File
@@ -1,119 +1,20 @@
.navbar-toggler { .top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
appearance: none; .brand-lockup { display: flex; align-items: center; gap: .75rem; }
cursor: pointer; .brand-mark { align-items: center; background: #2b75a8; border-radius: .45rem; color: #fff; display: flex; font-size: 1rem; height: 2.25rem; justify-content: center; text-decoration: none; transform: rotate(-8deg); width: 2.25rem; }
width: 3.5rem; .brand-mark i { transform: rotate(8deg); }
height: 2.5rem; .navbar-brand { color: #17212b; font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.25rem; font-weight: 700; letter-spacing: -.035em; line-height: .95; text-decoration: none; }
color: white; .navbar-brand .brand-cut { color: #2b75a8; }
position: absolute; .navbar-brand .brand-list { color: #17212b; }
top: 0.5rem; .navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
right: 1rem; .nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
border: 1px solid rgba(255, 255, 255, 0.1); .nav-item { padding: .1rem .75rem; }
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); .nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #c4d3dc; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
} .nav-item ::deep .nav-link i { color: #8fa7b7; font-size: 1rem; width: 1.1rem; }
.nav-item ::deep .nav-link:hover { background: rgba(255,255,255,.06); color: #fff; }
.navbar-toggler:checked { .nav-item ::deep a.active { background: #294655; color: #fff; }
background-color: rgba(255, 255, 255, 0.5); .nav-item ::deep a.active i { color: #72b5df; }
} .navbar-toggler { appearance: none; background: transparent; border: 1px solid #587382; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
.navbar-toggler::after { content: '☰'; }
.top-row { .nav-scrollable { display: none; }
height: 3.5rem; .navbar-toggler:checked ~ .nav-scrollable { display: block; }
background-color: rgba(0,0,0,0.4); @media (min-width: 641px) { .navbar-toggler { display: none; } .nav-scrollable { display: block; } }
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-list-check-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-check' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3.854 2.146a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 3.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 7.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 0 1 .708-.708l.146.147 1.146-1.147a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
}
.bi-box-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box' viewBox='0 0 16 16'%3E%3Cpath d='M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z'/%3E%3C/svg%3E");
}
.bi-boxes-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-boxes' viewBox='0 0 16 16'%3E%3Cpath d='M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434zM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567zM7.5 9.933l-2.75 1.571v3.134l2.75-1.571zm1 3.134 2.75 1.571v-3.134L8.5 9.933zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567zm2.242-2.433V3.504L8.5 5.076V8.21zM7.5 8.21V5.076L4.75 3.504v3.134zM5.258 2.643 8 4.21l2.742-1.567L8 1.076zM15 9.933l-2.75 1.571v3.134L15 13.067zM3.75 14.638v-3.134L1 9.933v3.134z'/%3E%3C/svg%3E");
}
.bi-cart-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-cart3' viewBox='0 0 16 16'%3E%3Cpath d='M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
}
.bi-building-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-building' viewBox='0 0 16 16'%3E%3Cpath d='M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z'/%3E%3Cpath d='M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z'/%3E%3C/svg%3E");
}
.bi-tools-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tools' viewBox='0 0 16 16'%3E%3Cpath d='M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242l-.914.305-.968-.968 2.617-2.654A3.003 3.003 0 0 0 13 0a3 3 0 1 0-.851 5.878L9.495 8.53l-2.675-2.675a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814L3.081 2.2 1 0ZM3 13a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm7.5-8.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
display: block;
position: relative;
overflow-y: auto;
flex-grow: 1;
}
}
+136 -42
View File
@@ -1,49 +1,143 @@
@page "/" @page "/"
@inject OverviewService OverviewService
@using CutList.Core.Formatting
<PageTitle>CutList - Home</PageTitle> <PageTitle>CutList - Overview</PageTitle>
<h1>CutList</h1> @if (loading)
{
<p><em>Loading overview…</em></p>
}
else
{
<section class="overview-header">
<div class="overview-actions">
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
</div>
</section>
<p class="lead">1D Bin Packing Optimization for Material Cutting</p> @if (loadError is not null)
{
<div class="alert alert-warning mt-4" role="alert">
<strong>Overview data is unavailable.</strong> @loadError
</div>
}
<div class="row mt-4"> <section class="overview-stats" aria-label="CutList statistics">
<div class="col-md-6 col-lg-4 mb-4"> <a href="jobs" class="overview-stat">
<div class="card h-100"> <span class="overview-stat-label">Jobs</span>
<div class="card-body"> <strong>@overview.TotalJobs</strong>
<h5 class="card-title">Jobs</h5> <small>@overview.OpenJobs open for planning</small>
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p> </a>
<a href="jobs" class="btn btn-primary">Go to Jobs</a> <a href="jobs" class="overview-stat">
</div> <span class="overview-stat-label">Parts required</span>
</div> <strong>@overview.TotalParts</strong>
</div> <small>Across all current jobs</small>
<div class="col-md-6 col-lg-4 mb-4"> </a>
<div class="card h-100"> <a href="stock" class="overview-stat">
<div class="card-body"> <span class="overview-stat-label">Stock options</span>
<h5 class="card-title">Materials</h5> <strong>@overview.ActiveStockItems</strong>
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p> <small>Active catalog lengths</small>
<a href="materials" class="btn btn-outline-primary">Manage Materials</a> </a>
</div> </section>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Cutting Tools</h5>
<p class="card-text">Configure cutting tools with their kerf widths for accurate waste calculations.</p>
<a href="tools" class="btn btn-outline-primary">Manage Tools</a>
</div>
</div>
</div>
</div>
<hr class="my-4" /> <section class="overview-grid">
<article class="overview-panel overview-recent-jobs">
<div class="overview-panel-heading">
<div>
<p class="eyebrow">Work queue</p>
<h2>Recently created jobs</h2>
</div>
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
</div>
@if (overview.RecentJobs.Count == 0)
{
<div class="overview-empty">
<i class="bi bi-layers" aria-hidden="true"></i>
<p>No jobs yet. Create a job to begin building your planning history.</p>
<a href="jobs/new" class="btn btn-primary">Create first job</a>
</div>
}
else
{
<div class="overview-job-list">
@foreach (var job in overview.RecentJobs)
{
<a href="jobs/@job.Id" class="overview-job-row">
<span class="overview-job-id">@job.JobNumber</span>
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
@if (job.IsLocked)
{
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
}
else
{
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
}
</a>
}
</div>
}
</article>
<h4>How It Works</h4> <article class="overview-panel overview-stock-panel">
<ol> <div class="overview-panel-heading">
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li> <div>
<li><strong>Add stock</strong> - Record which stock lengths you have on hand</li> <p class="eyebrow">Planning signal</p>
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li> <h2>Frequently specified stock</h2>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from</li> </div>
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li> <a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li> </div>
</ol> <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;
}
}
}
+238 -212
View File
@@ -13,13 +13,13 @@
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle> <PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1> <h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a> <a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
</div> </div>
@if (!IsNew && job.IsLocked) @if (!IsNew && job.IsLocked)
{ {
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3"> <div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only">
<div> <div>
<i class="bi bi-lock-fill me-2"></i> <i class="bi bi-lock-fill me-2"></i>
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes. <strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
@@ -229,17 +229,40 @@ else
</div> </div>
} }
@* Import Stock Modal *@ @* Add/Edit Stock Modal *@
@if (showImportModal) @if (showStockModal)
{ {
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);"> <div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Import Stock from Inventory</h5> <h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
<button type="button" class="btn-close" @onclick="CloseImportModal"></button> <button type="button" class="btn-close" @onclick="CloseStockModal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@if (editingStock == null)
{
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(stockModalTab == StockModalTab.Inventory ? "active" : "")"
type="button" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "")"
@onclick="() => stockModalTab = StockModalTab.Inventory">
From Inventory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(stockModalTab == StockModalTab.Custom ? "active" : "")"
type="button"
@onclick="() => stockModalTab = StockModalTab.Custom">
Custom Length
</button>
</li>
</ul>
}
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
@if (loadingImport) @if (loadingImport)
{ {
<div class="text-center py-4"> <div class="text-center py-4">
@@ -276,7 +299,6 @@ else
<tr> <tr>
<th style="width: 40px;"></th> <th style="width: 40px;"></th>
<th>Length</th> <th>Length</th>
<th>On Hand</th>
<th style="width: 120px;">Qty to Use</th> <th style="width: 120px;">Qty to Use</th>
<th style="width: 100px;">Priority</th> <th style="width: 100px;">Priority</th>
</tr> </tr>
@@ -289,7 +311,6 @@ else
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" /> <input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
</td> </td>
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td> <td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
<td>@candidate.StockItem.QuantityOnHand</td>
<td> <td>
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity" <input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
min="-1" disabled="@(!candidate.Selected)" /> min="-1" disabled="@(!candidate.Selected)" />
@@ -309,9 +330,109 @@ else
{ {
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div> <div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
} }
}
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
{
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stock Length</label>
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
<option value="">-- Select --</option>
@foreach (var stock in availableStockItems)
{
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Qty to Use</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
<small class="text-muted">-1 = unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
}
else
{
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-3">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="-1" />
<small class="text-muted">Use -1 for unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button> <button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
@if (importCandidates.Count > 0) @if (importCandidates.Count > 0)
{ {
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync" <button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
@@ -319,6 +440,17 @@ else
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "") Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
</button> </button>
} }
}
else if (stockModalTab == StockModalTab.Inventory && editingStock != null)
{
<button type="button" class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">Save Changes</button>
}
else
{
<button type="button" class="btn btn-primary" @onclick="SaveCustomStockAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -352,18 +484,16 @@ else
private int partSelectedMaterialId; private int partSelectedMaterialId;
private List<PartRow> partRows = new(); private List<PartRow> partRows = new();
// Stock form // Stock modal (unified add/edit, both inventory-sourced and custom-length)
private bool showStockForm; private enum StockModalTab { Inventory, Custom }
private bool showCustomStockForm; private bool showStockModal;
private StockModalTab stockModalTab = StockModalTab.Inventory;
private JobStock newStock = new(); private JobStock newStock = new();
private JobStock? editingStock; private JobStock? editingStock;
private string? stockErrorMessage; private string? stockErrorMessage;
private MaterialShape? stockSelectedShape; private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId; private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new(); private List<StockItem> availableStockItems = new();
// Import modal
private bool showImportModal;
private bool loadingImport; private bool loadingImport;
private List<ImportStockCandidate> importCandidates = new(); private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage; private string? importErrorMessage;
@@ -373,6 +503,7 @@ else
private MultiMaterialPackingSummary? summary; private MultiMaterialPackingSummary? summary;
private bool optimizing; private bool optimizing;
private bool lockingJob; private bool lockingJob;
private bool showLengthsInInches = true;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s); private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
@@ -381,6 +512,9 @@ else
private bool IsNew => !Id.HasValue; private bool IsNew => !Id.HasValue;
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null; private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
private string FormatResultLength(double inches) => showLengthsInInches
? ArchUnits.FormatInches(inches)
: ArchUnits.FormatFromInches(inches);
private async Task UnlockJob() private async Task UnlockJob()
{ {
@@ -556,8 +690,10 @@ else
<td> <td>
@if (!job.IsLocked) @if (!job.IsLocked)
{ {
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button> <div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
} }
</td> </td>
</tr> </tr>
@@ -743,31 +879,16 @@ else
<h5 class="mb-0">Stock for This Job</h5> <h5 class="mb-0">Stock for This Job</h5>
@if (!job.IsLocked) @if (!job.IsLocked)
{ {
<div class="d-flex gap-2"> <button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
} }
</div> </div>
<div class="card-body"> <div class="card-body">
@if (showStockForm)
{
@RenderStockFromInventoryForm()
}
else if (showCustomStockForm)
{
@RenderCustomStockForm()
}
@if (job.Stock.Count == 0) @if (job.Stock.Count == 0)
{ {
<div class="text-center py-4 text-muted"> <div class="text-center py-4 text-muted">
<p class="mb-2">No stock configured for this job.</p> <p class="mb-2">No stock configured for this job.</p>
<p class="small">Add stock from your inventory or define custom lengths.</p> <p class="small">Add stock from your inventory or define custom lengths.</p>
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p> <p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
</div> </div>
} }
else else
@@ -778,122 +899,6 @@ else
</div> </div>
}; };
private RenderFragment RenderStockFromInventoryForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stock Length</label>
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
<option value="">-- Select --</option>
@foreach (var stock in availableStockItems)
{
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@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 => private RenderFragment RenderStockTable() => __builder =>
{ {
<div class="table-responsive"> <div class="table-responsive">
@@ -929,8 +934,10 @@ else
<td> <td>
@if (!job.IsLocked) @if (!job.IsLocked)
{ {
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button> <div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
} }
</td> </td>
</tr> </tr>
@@ -984,6 +991,20 @@ else
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport"> <button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
<i class="bi bi-printer me-1"></i> Print Report <i class="bi bi-printer me-1"></i> Print Report
</button> </button>
<button class="btn btn-outline-secondary ms-2" @onclick="() => showLengthsInInches = !showLengthsInInches"
aria-pressed="@showLengthsInInches">
@(showLengthsInInches ? "Show feet + inches" : "Show all inches")
</button>
@if (!job.IsLocked)
{
<button class="btn btn-warning ms-2" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
}
} }
@if (job.OptimizedAt.HasValue) @if (job.OptimizedAt.HasValue)
{ {
@@ -1024,7 +1045,7 @@ else
<div class="col-md-3 col-6 mb-3"> <div class="col-md-3 col-6 mb-3">
<div class="card text-center"> <div class="card text-center">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2> <h2 class="card-title mb-0">@FormatResultLength(summary.TotalWaste)</h2>
<p class="card-text text-muted">Total Waste</p> <p class="card-text text-muted">Total Waste</p>
</div> </div>
</div> </div>
@@ -1039,29 +1060,27 @@ else
</div> </div>
</div> </div>
<!-- Purchase List --> @if (job.CuttingTool != null)
<div class="card mb-4 print-purchase-list"> {
<div class="print-cut-method">
<strong>Cut Method:</strong> @job.CuttingTool.Name
<span class="ms-2">Kerf: @FormatResultLength((double)job.CuttingTool.KerfInches)</span>
</div>
}
<!-- Material List: required stock lengths not covered by job stock -->
<div class="card mb-4 print-material-list">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5> <h5 class="mb-0"><i class="bi bi-box-seam me-2"></i>Material List</h5>
@if (job.IsLocked) @if (job.IsLocked)
{ {
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span> <span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
} }
</div> </div>
<div class="card-body"> <div class="card-body">
@if (summary.TotalToBePurchasedBins == 0) @if (summary.TotalToBePurchasedBins == 0)
{ {
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p> <p class="text-muted mb-0">No additional material lengths are required beyond the stock configured for this job.</p>
} }
else else
{ {
@@ -1081,7 +1100,7 @@ else
{ {
<tr> <tr>
<td>@materialResult.Material.DisplayName</td> <td>@materialResult.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches(group.Key)</td> <td>@FormatResultLength(group.Key)</td>
<td class="text-end">@group.Count()</td> <td class="text-end">@group.Count()</td>
</tr> </tr>
} }
@@ -1089,7 +1108,7 @@ else
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="fw-bold"> <tr class="fw-bold">
<td colspan="2">Total</td> <td colspan="2">Total Required</td>
<td class="text-end">@summary.TotalToBePurchasedBins bars</td> <td class="text-end">@summary.TotalToBePurchasedBins bars</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -1125,7 +1144,7 @@ else
{ {
<div class="alert alert-danger"> <div class="alert alert-danger">
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> — <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> </div>
} }
@@ -1155,16 +1174,21 @@ else
{ {
<tr> <tr>
<td>@binNum</td> <td>@binNum</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td> <td style="white-space: nowrap;">@FormatResultLength(entry.Bin.Length)</td>
<td> <td>
@foreach (var item in entry.Bin.Items) @foreach (var item in entry.Bin.Items)
{ {
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;"> <span class="badge me-1 mb-1 fs-6 cut-part-badge">
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})") @if (!string.IsNullOrWhiteSpace(item.Name))
{
<span class="cut-part-badge-name">@item.Name</span>
<span class="cut-part-badge-divider" aria-hidden="true"></span>
}
<span class="cut-part-badge-length">@FormatResultLength(item.Length)</span>
</span> </span>
} }
</td> </td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td> <td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
</tr> </tr>
binNum++; binNum++;
} }
@@ -1228,21 +1252,33 @@ else
await JS.InvokeVoidAsync("printWithTitle", filename); await JS.InvokeVoidAsync("printWithTitle", filename);
} }
private void ShowAddCustomStock() private async Task ShowAddStockModal()
{ {
editingStock = null; editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true }; newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
stockSelectedShape = null; stockSelectedShape = null;
showStockForm = false; stockSelectedMaterialId = 0;
showCustomStockForm = true; availableStockItems.Clear();
stockErrorMessage = null; stockErrorMessage = null;
importErrorMessage = null;
importCandidates.Clear();
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
showStockModal = true;
if (job.Parts.Count > 0)
{
await LoadImportCandidatesAsync();
}
} }
private void CancelStockForm() private void CloseStockModal()
{ {
showStockForm = false; showStockModal = false;
showCustomStockForm = false;
editingStock = null; editingStock = null;
newStock = new();
importCandidates.Clear();
stockErrorMessage = null;
importErrorMessage = null;
} }
private async Task OnStockShapeChanged() private async Task OnStockShapeChanged()
@@ -1257,17 +1293,10 @@ else
{ {
newStock.MaterialId = stockSelectedMaterialId; newStock.MaterialId = stockSelectedMaterialId;
newStock.StockItemId = null; newStock.StockItemId = null;
if (stockSelectedMaterialId > 0) await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
{
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
}
else
{
availableStockItems.Clear();
}
} }
private void EditStock(JobStock stock) private async Task EditStock(JobStock stock)
{ {
editingStock = stock; editingStock = stock;
newStock = new JobStock newStock = new JobStock
@@ -1285,18 +1314,23 @@ else
stockSelectedShape = stock.Material?.Shape; stockSelectedShape = stock.Material?.Shape;
stockSelectedMaterialId = stock.MaterialId; stockSelectedMaterialId = stock.MaterialId;
stockErrorMessage = null; stockErrorMessage = null;
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
showStockModal = true;
if (stock.IsCustomLength) if (!stock.IsCustomLength)
{ {
showStockForm = false; // Load the candidate stock lengths without going through OnStockMaterialChanged,
showCustomStockForm = true; // which would reset newStock.StockItemId and blank the prefilled selection.
// Awaited (not fire-and-forget) so Blazor re-renders once the list arrives.
await LoadAvailableStockItemsAsync(stock.MaterialId);
} }
else }
private async Task LoadAvailableStockItemsAsync(int materialId)
{ {
showStockForm = true; availableStockItems = materialId > 0
showCustomStockForm = false; ? await JobService.GetAvailableStockForMaterialAsync(materialId)
_ = OnStockMaterialChanged(); : new List<StockItem>();
}
} }
private async Task SaveStockFromInventoryAsync() private async Task SaveStockFromInventoryAsync()
@@ -1321,9 +1355,9 @@ else
return; 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; return;
} }
@@ -1348,7 +1382,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false; showStockModal = false;
editingStock = null; editingStock = null;
packResult = null; packResult = null;
summary = null; summary = null;
@@ -1395,7 +1429,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false; showStockModal = false;
editingStock = null; editingStock = null;
packResult = null; packResult = null;
summary = null; summary = null;
@@ -1409,13 +1443,12 @@ else
summary = null; summary = null;
} }
// Import modal methods // Loads inventory stock candidates for the modal's "From Inventory" tab
private async Task ShowImportModal() private async Task LoadImportCandidatesAsync()
{ {
importErrorMessage = null; importErrorMessage = null;
importCandidates.Clear(); importCandidates.Clear();
loadingImport = true; loadingImport = true;
showImportModal = true;
try try
{ {
@@ -1450,13 +1483,6 @@ else
} }
} }
private void CloseImportModal()
{
showImportModal = false;
importCandidates.Clear();
importErrorMessage = null;
}
private void ToggleAllImportCandidates(bool selected) private void ToggleAllImportCandidates(bool selected)
{ {
foreach (var c in importCandidates) foreach (var c in importCandidates)
@@ -1491,7 +1517,7 @@ else
} }
job = (await JobService.GetByIdAsync(Id!.Value))!; job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false; showStockModal = false;
importCandidates.Clear(); importCandidates.Clear();
packResult = null; packResult = null;
summary = null; summary = null;
@@ -62,9 +62,11 @@ else
<td>@(job.CuttingTool?.Name ?? "-")</td> <td>@(job.CuttingTool?.Name ?? "-")</td>
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td> <td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
<td> <td>
<div class="table-actions">
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a> <a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button> <button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td> </td>
</tr> </tr>
} }
@@ -62,7 +62,7 @@ else
<td>@material.Grade</td> <td>@material.Grade</td>
<td>@material.Size</td> <td>@material.Size</td>
<td> <td>
<div class="d-flex gap-1"> <div class="table-actions">
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a> <a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
</div> </div>
@@ -70,94 +70,6 @@ else
</div> </div>
</div> </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> </div>
} }
@@ -167,19 +79,10 @@ else
private StockItem stockItem = new(); private StockItem stockItem = new();
private List<Material> materials = new(); private List<Material> materials = new();
private List<StockTransaction> transactions = new();
private bool loading = true; private bool loading = true;
private bool saving; private bool saving;
private string? errorMessage; 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; private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -195,83 +98,10 @@ else
return; return;
} }
stockItem = existing; stockItem = existing;
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
} }
loading = false; 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() private async Task SaveStockItemAsync()
{ {
errorMessage = null; errorMessage = null;
+3 -14
View File
@@ -12,8 +12,8 @@
</div> </div>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Stock items represent the specific lengths of material you have available for cutting. Each stock item links Stock items represent the specific lengths of material you can cut from. Add the lengths you typically
a material to a length and tracks how many pieces you have on hand. work with here, then pick from them when adding stock to a job.
</p> </p>
@if (loading) @if (loading)
@@ -46,7 +46,6 @@ else
<th>Grade</th> <th>Grade</th>
<th>Size</th> <th>Size</th>
<th>Length</th> <th>Length</th>
<th>On Hand</th>
<th style="width: 100px;">Actions</th> <th style="width: 100px;">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -60,17 +59,7 @@ else
<td>@item.Material.Size</td> <td>@item.Material.Size</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td> <td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td> <td>
@if (item.QuantityOnHand > 0) <div class="table-actions">
{
<span class="badge bg-success">@item.QuantityOnHand</span>
}
else
{
<span class="badge bg-secondary">0</span>
}
</td>
<td>
<div class="d-flex gap-1">
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a> <a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
</div> </div>
@@ -90,8 +90,10 @@ else
} }
</td> </td>
<td> <td>
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button> <button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td> </td>
</tr> </tr>
} }
@@ -310,7 +310,6 @@ public class JobsController : ControllerBase
LengthInches = s.LengthInches, LengthInches = s.LengthInches,
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
Name = s.Name, Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
IsActive = s.IsActive IsActive = s.IsActive
}).ToList()); }).ToList());
} }
@@ -63,7 +63,6 @@ public class StockItemsController : ControllerBase
MaterialId = dto.MaterialId, MaterialId = dto.MaterialId,
LengthInches = (decimal)lengthInches, LengthInches = (decimal)lengthInches,
Name = dto.Name, Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes Notes = dto.Notes
}; };
@@ -118,87 +117,6 @@ public class StockItemsController : ControllerBase
return Ok(items.Select(MapToDto).ToList()); 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() private static StockItemDto MapToDto(StockItem s) => new()
{ {
Id = s.Id, Id = s.Id,
@@ -207,20 +125,7 @@ public class StockItemsController : ControllerBase
LengthInches = s.LengthInches, LengthInches = s.LengthInches,
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
Name = s.Name, Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes, Notes = s.Notes,
IsActive = s.IsActive 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
};
} }
-1
View File
@@ -103,7 +103,6 @@ public class CatalogStockItemDto
{ {
public decimal LengthInches { get; set; } public decimal LengthInches { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
-39
View File
@@ -8,7 +8,6 @@ public class StockItemDto
public decimal LengthInches { get; set; } public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty; public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }
@@ -18,7 +17,6 @@ public class CreateStockItemDto
public int MaterialId { get; set; } public int MaterialId { get; set; }
public string Length { get; set; } = string.Empty; public string Length { get; set; } = string.Empty;
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -28,40 +26,3 @@ public class UpdateStockItemDto
public string? Name { get; set; } public string? Name { get; set; }
public string? Notes { 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; }
}
-19
View File
@@ -13,7 +13,6 @@ public class ApplicationDbContext : DbContext
public DbSet<Material> Materials => Set<Material>(); public DbSet<Material> Materials => Set<Material>();
public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>(); public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>();
public DbSet<StockItem> StockItems => Set<StockItem>(); public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<StockTransaction> StockTransactions => Set<StockTransaction>();
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>(); public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
public DbSet<Job> Jobs => Set<Job>(); public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobPart> JobParts => Set<JobPart>(); public DbSet<JobPart> JobParts => Set<JobPart>();
@@ -157,24 +156,6 @@ public class ApplicationDbContext : DbContext
entity.HasIndex(e => new { e.MaterialId, e.LengthInches }).IsUnique(); 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 // CuttingTool
modelBuilder.Entity<CuttingTool>(entity => modelBuilder.Entity<CuttingTool>(entity =>
{ {
-2
View File
@@ -6,12 +6,10 @@ public class StockItem
public int MaterialId { get; set; } public int MaterialId { get; set; }
public decimal LengthInches { get; set; } public decimal LengthInches { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public int QuantityOnHand { get; set; } = 0;
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public Material Material { get; set; } = null!; 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": []
}
}
@@ -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) .HasMaxLength(255)
.HasColumnType("nvarchar(255)"); .HasColumnType("nvarchar(255)");
b.Property<int>("QuantityOnHand")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -339,44 +336,6 @@ namespace CutList.Web.Migrations
b.ToTable("StockItems"); 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 => modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{ {
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions"); b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
@@ -632,24 +591,6 @@ namespace CutList.Web.Migrations
b.Navigation("Material"); 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 => modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{ {
b.Navigation("Jobs"); b.Navigation("Jobs");
@@ -670,11 +611,6 @@ namespace CutList.Web.Migrations
b.Navigation("StockItems"); b.Navigation("StockItems");
}); });
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("Transactions");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+1
View File
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
builder.Services.AddScoped<MaterialService>(); builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<StockItemService>(); builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<JobService>(); builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<OverviewService>();
builder.Services.AddScoped<CutListPackingService>(); builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>(); builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<CatalogService>(); builder.Services.AddScoped<CatalogService>();
-2
View File
@@ -362,7 +362,6 @@ public class CatalogService
MaterialId = material.Id, MaterialId = material.Id,
LengthInches = dto.LengthInches, LengthInches = dto.LengthInches,
Name = dto.Name, Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes, Notes = dto.Notes,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -388,7 +387,6 @@ public class CatalogService
{ {
LengthInches = s.LengthInches, LengthInches = s.LengthInches,
Name = s.Name, Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes Notes = s.Notes
}).ToList(); }).ToList();
} }
+18 -37
View File
@@ -46,10 +46,10 @@ public class CutListPackingService
// Build stock bins // Build stock bins
var stockBins = new List<StockBinSource>(); var stockBins = new List<StockBinSource>();
// Check if job has specific stock configured for this material // Use job-specific stock configuration. Jobs must have stock explicitly configured -
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0) // 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)) foreach (var stock in materialJobStock.OrderBy(s => s.Priority))
{ {
stockBins.Add(new StockBinSource 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) if (stockBins.Count == 0)
{ {
@@ -139,7 +108,14 @@ public class CutListPackingService
var inStockBins = new List<Bin>(); var inStockBins = new List<Bin>();
var toBePurchasedBins = 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 var remainingStock = stockBins
.Where(s => s.IsInStock && s.Quantity > 0) .Where(s => s.IsInStock && s.Quantity > 0)
.GroupBy(s => s.LengthInches) .GroupBy(s => s.LengthInches)
@@ -149,9 +125,14 @@ public class CutListPackingService
{ {
var binLength = (decimal)bin.Length; var binLength = (decimal)bin.Length;
// Check if this can come from in-stock if (unlimitedInStockLengths.Contains(binLength))
if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
{ {
// 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); inStockBins.Add(bin);
remainingStock[binLength] = remaining - 1; remainingStock[binLength] = remaining - 1;
} }
+9 -24
View File
@@ -42,35 +42,17 @@ public class JobService
{ {
await using var context = _factory.CreateDbContext(); await using var context = _factory.CreateDbContext();
job ??= new Job(); job ??= new Job();
job.JobNumber = await GenerateJobNumberAsync(context); job.JobNumber = CreateTemporaryJobNumber();
job.CreatedAt = DateTime.UtcNow; job.CreatedAt = DateTime.UtcNow;
context.Jobs.Add(job); context.Jobs.Add(job);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
job.JobNumber = $"JOB-{job.Id}";
await context.SaveChangesAsync();
return job; return job;
} }
public async Task<string> GenerateJobNumberAsync() private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
{
await using var context = _factory.CreateDbContext();
return await GenerateJobNumberAsync(context);
}
private static async Task<string> GenerateJobNumberAsync(ApplicationDbContext context)
{
var maxNumber = await context.Jobs
.Where(j => j.JobNumber.StartsWith("JOB-"))
.Select(j => j.JobNumber)
.MaxAsync() as string;
if (maxNumber == null)
return "JOB-00001";
var numPart = maxNumber.Substring(4);
if (int.TryParse(numPart, out var num))
return $"JOB-{num + 1:D5}";
return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}";
}
public async Task<Job> QuickCreateAsync(string? customer = null) public async Task<Job> QuickCreateAsync(string? customer = null)
{ {
@@ -142,7 +124,7 @@ public class JobService
var duplicate = new Job var duplicate = new Job
{ {
JobNumber = await GenerateJobNumberAsync(context), JobNumber = CreateTemporaryJobNumber(),
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)", Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
Customer = original.Customer, Customer = original.Customer,
CuttingToolId = original.CuttingToolId, CuttingToolId = original.CuttingToolId,
@@ -153,6 +135,9 @@ public class JobService
context.Jobs.Add(duplicate); context.Jobs.Add(duplicate);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
duplicate.JobNumber = $"JOB-{duplicate.Id}";
await context.SaveChangesAsync();
// Copy parts // Copy parts
foreach (var part in original.Parts) foreach (var part in original.Parts)
{ {
+92
View File
@@ -0,0 +1,92 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class OverviewService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<OverviewSnapshot> GetSnapshotAsync()
{
await using var context = _factory.CreateDbContext();
var recentJobs = await context.Jobs
.AsNoTracking()
.OrderByDescending(j => j.CreatedAt)
.Take(8)
.Select(j => new RecentJobOverview(
j.Id,
j.JobNumber,
j.Name,
j.Customer,
j.CreatedAt,
j.LockedAt != null,
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
.ToListAsync();
var frequentStock = await context.JobStocks
.AsNoTracking()
.GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })
.Select(g => new
{
g.Key.MaterialId,
g.Key.LengthInches,
g.Key.Shape,
g.Key.Size,
JobCount = g.Select(s => s.JobId).Distinct().Count()
})
.OrderByDescending(s => s.JobCount)
.ThenBy(s => s.Shape)
.ThenBy(s => s.Size)
.ThenBy(s => s.LengthInches)
.Take(5)
.ToListAsync();
var totalJobs = await context.Jobs.CountAsync();
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
return new OverviewSnapshot(
totalJobs,
openJobs,
totalParts,
activeStockItems,
recentJobs,
frequentStock.Select(s => new FrequentStockOverview(
s.MaterialId,
$"{s.Shape.GetDisplayName()} - {s.Size}",
s.LengthInches,
s.JobCount)).ToList());
}
}
public record OverviewSnapshot(
int TotalJobs,
int OpenJobs,
int TotalParts,
int ActiveStockItems,
IReadOnlyList<RecentJobOverview> RecentJobs,
IReadOnlyList<FrequentStockOverview> FrequentStock);
public record RecentJobOverview(
int Id,
string JobNumber,
string? Name,
string? Customer,
DateTime CreatedAt,
bool IsLocked,
int PartCount);
public record FrequentStockOverview(
int MaterialId,
string MaterialName,
decimal LengthInches,
int JobCount);
-143
View File
@@ -108,147 +108,4 @@ public class StockItemService
return await query.AnyAsync(); 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;
}
} }
+95 -3
View File
@@ -88,8 +88,8 @@ main {
} }
.top-row { .top-row {
background-color: #f3f1ee; background-color: #edf2f5;
border-bottom: 1px solid #e0ddd8; border-bottom: 1px solid #d4dee5;
justify-content: flex-end; justify-content: flex-end;
height: 3.5rem; height: 3.5rem;
display: flex; display: flex;
@@ -145,7 +145,7 @@ main {
width: 0; width: 0;
} }
.top-row, article { article {
padding-left: 2rem !important; padding-left: 2rem !important;
padding-right: 1.5rem !important; padding-right: 1.5rem !important;
} }
@@ -225,3 +225,95 @@ h1 {
text-align: left; text-align: left;
} }
} }
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
:root {
--ink: #17212b;
--muted: #617282;
--paper: #f3f6f8;
--paper-bright: #ffffff;
--line: #d4dee5;
--accent: #2b75a8;
--accent-dark: #1d5d8d;
--navy: #203747;
--rail: #18242e;
}
html, body { background: var(--paper); color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { letter-spacing: -.01em; }
main { background: var(--paper); box-shadow: none; min-width: 0; }
.sidebar { background: var(--rail); border-right: 1px solid #314653; }
.content { margin: 0 auto; max-width: 1440px; padding: 2.75rem clamp(1.5rem, 4vw, 4.5rem) 4rem; }
.eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 650; letter-spacing: -.025em; }
h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem); line-height: 1.04; }
h2 { font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.12; }
.job-title { font-size: clamp(1.5rem, 2.1vw, 2.1rem); line-height: 1.12; }
a, .btn-link { color: var(--accent-dark); }
.btn { border-radius: .35rem; font-size: .85rem; font-weight: 750; letter-spacing: .01em; padding: .65rem 1rem; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.btn-success { background: #267c6c; border-color: #267c6c; }
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
.card-header { background: #edf2f5; }
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
.table > :not(caption) > * > * { padding: .85rem .75rem; }
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
.alert { border-radius: .4rem; border-width: 1px; }
/* Results cut badges keep the part number and its length visually distinct. */
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
.cut-part-badge-name { padding: .35em .55em; }
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
/* Overview */
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
.overview-recent-jobs { min-width: 0; }
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
/* Dashboard */
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
.dashboard-hero .eyebrow { color: #a8c5d7; }
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
.btn-quiet:hover { color: #8fc5e4; }
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
.dashboard-section { margin-top: 4rem; }
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
+40 -4
View File
@@ -141,14 +141,27 @@
display: none; display: none;
} }
/* The selected tool belongs on the paper cut report, not in the interactive results layout. */
.print-cut-method {
display: none;
}
/* Print styles - Compact layout to save paper */ /* Print styles - Compact layout to save paper */
@media print { @media print {
body { body {
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: economy;
print-color-adjust: exact; print-color-adjust: economy;
font-size: 10pt; font-size: 10pt;
} }
*,
*::before,
*::after {
background: transparent !important;
box-shadow: none !important;
}
.print-screen-only,
.sidebar, .sidebar,
.top-row, .top-row,
.page > main > .top-row, .page > main > .top-row,
@@ -304,8 +317,16 @@
margin: 0 !important; margin: 0 !important;
} }
/* Keep purchase list with cut lists to save paper */ .print-cut-method {
.print-purchase-list { display: block !important;
border: 1px solid #ccc;
font-size: 9pt;
margin: -0.25rem 0 0.5rem;
padding: 0.3rem 0.75rem;
}
/* Keep material list with cut lists to save paper */
.print-material-list {
break-inside: avoid; break-inside: avoid;
page-break-inside: avoid; page-break-inside: avoid;
} }
@@ -329,6 +350,15 @@
border: 1px solid #999; border: 1px solid #999;
} }
/* Badges lose their blue fills in print, so force their white screen text and divider to black. */
.cut-part-badge {
color: #000 !important;
}
.cut-part-badge-divider {
border-left: 1px solid #000 !important;
}
/* Cut list tables: hide screen header, show repeating print header in thead */ /* Cut list tables: hide screen header, show repeating print header in thead */
.cutlist-material-screen-header { .cutlist-material-screen-header {
display: none !important; display: none !important;
@@ -365,6 +395,12 @@
padding: 0 !important; padding: 0 !important;
} }
/* Keep each stock-bar result, its cuts, and its waste on one printed page. */
.cutlist-material-card tbody tr {
break-inside: avoid;
page-break-inside: avoid;
}
/* Reduce spacing */ /* Reduce spacing */
.mb-4 { .mb-4 {
margin-bottom: 0.5rem !important; margin-bottom: 0.5rem !important;
+14
View File
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -69,6 +71,18 @@ Global
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU {E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU {E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU {E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
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.
-83
View File
@@ -1,83 +0,0 @@
# Alro Steel SmartGrid Scraper — Remaining Steps
## Status: Script is READY TO RUN
The scraper at `scripts/AlroCatalog/scrape_alro.py` is complete and tested. Discovery mode confirmed it works correctly against the live site.
## What's Done
1. Script written with correct ASP.NET control IDs (discovered via `--discover` mode)
2. Level 1 (main grid) navigation: working
3. Level 2 (popup grid) navigation: working
4. Level 3 (dims panel) scraping: working — uses cascading dropdowns `ddlDimA``ddlDimB``ddlDimC``ddlLength`
5. Grade filter: 11 common grades (A-36, 1018, 1045, 1144, 12L14, etc.)
6. Size string normalization: "1-1/2\"" matches O'Neal format
7. Progress save/resume: working
8. Discovery mode verified: A-36 Round bars → 27 sizes, 80 items (lengths include "20 FT", "Custom Cut List", "Drop/Remnant" — non-stock entries filtered out in catalog builder)
## Remaining Steps
### Step 1: Run the full scrape
```bash
cd C:\Users\aisaacs\Desktop\Projects\CutList
python scripts/AlroCatalog/scrape_alro.py
```
- This scrapes all 3 categories (Bars, Pipe/Tube, Structural) for 11 filtered grades
- Takes ~30-60 minutes (cascading dropdown selections with 1.5s delay each)
- Progress saved incrementally to `scripts/AlroCatalog/alro-scrape-progress.json`
- If interrupted, resume with `python scripts/AlroCatalog/scrape_alro.py --resume`
- To scrape ALL grades: `python scripts/AlroCatalog/scrape_alro.py --all-grades`
### Step 2: Review output
- Output: `CutList.Web/Data/SeedData/alro-catalog.json`
- Verify material counts, shapes, sizes
- Spot-check dimensions against myalro.com
- Compare shape coverage to O'Neal catalog
### Step 3: Post-scrape adjustments (if needed)
**Dimension mapping for Structural/Pipe shapes**: The `build_size_and_dims()` function handles all shapes but Structural (Angle, Channel, Beam) and Pipe/Tube shapes haven't been tested live yet. After scraping, check the screenshots in `scripts/AlroCatalog/screenshots/` to verify dimension mapping. The first item of each new shape gets a screenshot + HTML dump.
**Known dimension mapping assumptions:**
- Angle: DimA = leg size, DimB = thickness → `"leg1 x leg2 x thickness"` (assumes equal legs)
- Channel: DimA = height, DimB = flange → needs verification
- IBeam: DimA = depth, DimB = weight/ft → `"W{depth} x {wt}"`
- SquareTube: DimA = size, DimB = wall
- RectTube: DimA = width, DimB = height, DimC = wall
- RoundTube: DimA = OD, DimB = wall
- Pipe: DimA = NPS, DimB = schedule
**If dimension mapping is wrong for a shape**: Edit the `build_size_and_dims()` function in `scrape_alro.py` and re-run just the catalog builder:
```python
python -c "
import json
from scripts.AlroCatalog.scrape_alro import build_catalog
data = json.load(open('scripts/AlroCatalog/alro-scrape-progress.json'))
catalog = build_catalog(data['items'])
json.dump(catalog, open('CutList.Web/Data/SeedData/alro-catalog.json', 'w'), indent=2)
"
```
### Step 4: Part numbers (optional future enhancement)
The current scraper captures sizes and lengths but NOT part numbers. To get part numbers, the script would need to:
1. Select DimA + DimB + Length
2. Click the "Next >" button (`btnSearch`)
3. Capture part number from the results panel
4. Click Back
This adds significant time per item. The catalog works without part numbers — the supplierOfferings have empty partNumber/supplierDescription fields.
## Key Files
| File | Purpose |
|------|---------|
| `scripts/AlroCatalog/scrape_alro.py` | The scraper script |
| `scripts/AlroCatalog/alro-scrape-progress.json` | Incremental progress (resume support) |
| `scripts/AlroCatalog/screenshots/` | Discovery HTML/screenshots per shape |
| `CutList.Web/Data/SeedData/alro-catalog.json` | Final output (same schema as oneals-catalog.json) |
| `CutList.Web/Data/SeedData/oneals-catalog.json` | Reference format |
## Grade Filter (editable in script)
Located at line ~50 in `scrape_alro.py`. Current filter:
- A-36, 1018 CF, 1018 HR, 1044 HR, 1045 CF, 1045 HR, 1045 TG&P
- 1144 CF, 1144 HR, 12L14 CF, A311/Stressproof
To add/remove grades, edit the `GRADE_FILTER` set in the script.
@@ -1,976 +0,0 @@
{
"completed": [
[
"Bars",
"A-36",
"ROUND"
],
[
"Bars",
"A-36",
"FLAT"
]
],
"items": [
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".188",
"dim_a_text": "3/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".188",
"dim_a_text": "3/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
}
]
}
-790
View File
@@ -1,790 +0,0 @@
#!/usr/bin/env python3
"""
Alro Steel SmartGrid Scraper
Scrapes myalro.com's SmartGrid for Carbon Steel materials and outputs
a catalog JSON matching the O'Neal catalog format.
Usage:
python scrape_alro.py # Scrape filtered grades (resumes from saved progress)
python scrape_alro.py --all-grades # Scrape ALL grades (slow)
python scrape_alro.py --discover # Scrape first item only, dump HTML/screenshots
python scrape_alro.py --fresh # Start fresh, ignoring saved progress
"""
import asyncio
import json
import re
import sys
import logging
from datetime import datetime, timezone
from pathlib import Path
from playwright.async_api import async_playwright, Page, TimeoutError as PwTimeout
from playwright_stealth import Stealth
# ── Logging ──────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)
# ── Paths ────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).parent.resolve()
OUTPUT_PATH = (SCRIPT_DIR / "../../CutList.Web/Data/SeedData/alro-catalog.json").resolve()
PROGRESS_PATH = SCRIPT_DIR / "alro-scrape-progress.json"
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
# ── Config ───────────────────────────────────────────────────────────
BASE_URL = "https://www.myalro.com/SmartGrid.aspx?PT=Steel&Clear=true"
DELAY = 5 # seconds between postback clicks
TIMEOUT = 15_000 # ms for element waits
CS_ROW = 4 # Carbon Steel row index in main grid
CATEGORIES = ["Bars", "Pipe / Tube", "Structural"]
# ┌─────────────────────────────────────────────────────────────────┐
# │ GRADE FILTER — only these grades will be scraped. │
# │ Use --all-grades flag to override and scrape everything. │
# │ Grade names must match the gpname attribute exactly. │
# └─────────────────────────────────────────────────────────────────┘
GRADE_FILTER = {
# Common structural / general purpose
"A-36",
# Mild steel
"1018 CF",
"1018 HR",
# Medium carbon (shafts, gears, pins)
"1045 CF",
"1045 HR",
"1045 TG&P",
# Free-machining
"1144 CF",
"1144 HR",
"12L14 CF",
# Hot-rolled plate/bar
"1044 HR",
# Stressproof (high-strength shafting)
"A311/Stressproof",
}
# Alro shape column header → our MaterialShape enum
SHAPE_MAP = {
"ROUND": "RoundBar",
"FLAT": "FlatBar",
"SQUARE": "SquareBar",
"ANGLE": "Angle",
"CHANNEL": "Channel",
"BEAM": "IBeam",
"SQ TUBE": "SquareTube",
"SQUARE TUBE": "SquareTube",
"REC TUBE": "RectangularTube",
"RECT TUBE": "RectangularTube",
"RECTANGULAR TUBE": "RectangularTube",
"ROUND TUBE": "RoundTube",
"RND TUBE": "RoundTube",
"PIPE": "Pipe",
}
# ── ASP.NET control IDs ─────────────────────────────────────────
_CP = "ctl00_ContentPlaceHolder1"
_PU = f"{_CP}_pnlPopUP"
ID = dict(
main_grid = f"{_CP}_grdMain",
popup_grid = f"{_PU}_grdPopUp",
popup_window = f"{_PU}_Window",
dims_panel = f"{_PU}_upnlDims",
back_btn = f"{_PU}_btnBack",
# Dimension dropdowns (cascading: A → B → C → Length)
dim_a = f"{_PU}_ddlDimA",
dim_b = f"{_PU}_ddlDimB",
dim_c = f"{_PU}_ddlDimC",
dim_length = f"{_PU}_ddlLength",
btn_next = f"{_PU}_btnSearch",
)
# Postback targets ($ separators)
PB = dict(
main_grid = "ctl00$ContentPlaceHolder1$grdMain",
popup_grid = "ctl00$ContentPlaceHolder1$pnlPopUP$grdPopUp",
back_btn = "ctl00$ContentPlaceHolder1$pnlPopUP$btnBack",
popup = "ctl00$ContentPlaceHolder1$pnlPopUP",
dim_a = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimA",
dim_b = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimB",
dim_c = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimC",
)
# ═══════════════════════════════════════════════════════════════════════
# Utility helpers
# ═══════════════════════════════════════════════════════════════════════
def parse_fraction(s: str) -> float | None:
"""Parse fraction/decimal string → float. '1-1/4' → 1.25, '.250' → 0.25"""
if not s:
return None
s = s.strip().strip('"\'')
# Collapse double spaces from Alro dropdown text ("1 1/4" → "1 1/4")
s = re.sub(r"\s+", " ", s)
if not s:
return None
try:
return float(s)
except ValueError:
pass
# Mixed fraction: "1-1/4" or "1 1/4"
m = re.match(r"^(\d+)[\s-](\d+)/(\d+)$", s)
if m:
return int(m[1]) + int(m[2]) / int(m[3])
m = re.match(r"^(\d+)/(\d+)$", s)
if m:
return int(m[1]) / int(m[2])
m = re.match(r"^(\d+)$", s)
if m:
return float(m[1])
return None
def decimal_to_fraction(value: float) -> str:
"""0.25 → '1/4', 1.25 → '1-1/4', 3.0 → '3'"""
if value <= 0:
return "0"
whole = int(value)
frac = value - whole
if abs(frac) < 0.001:
return str(whole)
from math import gcd
sixteenths = round(frac * 16)
if sixteenths == 16:
return str(whole + 1)
g = gcd(sixteenths, 16)
num, den = sixteenths // g, 16 // g
frac_s = f"{num}/{den}"
return f"{whole}-{frac_s}" if whole else frac_s
def normalize_dim_text(s: str) -> str:
"""Normalize dimension text: '1 1/4''1-1/4', '3/16''3/16'"""
s = re.sub(r"\s+", " ", s.strip())
# "1 1/4" → "1-1/4" (mixed fraction with space → hyphen)
s = re.sub(r"^(\d+)\s+(\d+/\d+)$", r"\1-\2", s)
return s
def parse_length_to_inches(text: str) -> float | None:
"""Parse length string to inches. \"20'\" → 240, \"240\" → 240"""
s = text.strip().upper()
s = re.sub(r"\s*(RL|RANDOM.*|LENGTHS?|EA|EACH|STOCK)\s*", "", s).strip()
m = re.match(r"^(\d+(?:\.\d+)?)\s*['\u2032]", s)
if m:
return float(m[1]) * 12
m = re.match(r"^(\d+(?:\.\d+)?)\s*FT", s)
if m:
return float(m[1]) * 12
m = re.match(r'^(\d+(?:\.\d+)?)\s*"?\s*$', s)
if m:
v = float(m[1])
return v * 12 if v <= 30 else v
return None
# ═══════════════════════════════════════════════════════════════════════
# SmartGrid navigation
# ═══════════════════════════════════════════════════════════════════════
async def wait_for_update(page: Page, timeout: int = TIMEOUT):
"""Wait for ASP.NET partial postback to finish."""
try:
await page.wait_for_load_state("networkidle", timeout=timeout)
except PwTimeout:
log.warning(" networkidle timeout continuing")
await asyncio.sleep(0.5)
async def do_postback(page: Page, target: str, arg: str):
"""Execute a __doPostBack call."""
await page.evaluate(f"__doPostBack('{target}', '{arg}')")
async def click_category(page: Page, category: str) -> bool:
"""Click a category blue-button for Carbon Steel in the main grid."""
log.info(f"Clicking main grid: {category} (row {CS_ROW})")
arg = f"{category}${CS_ROW}"
link = await page.query_selector(
f"#{ID['main_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
)
if not link:
log.error(f" Button not found for {arg}")
return False
parent = await link.evaluate_handle("el => el.parentElement")
await parent.as_element().click()
try:
await page.wait_for_selector(f"#{ID['popup_grid']}", state="visible", timeout=TIMEOUT)
await wait_for_update(page)
return True
except PwTimeout:
log.error(f" Popup did not appear for {category}")
return False
async def scrape_popup_grid(page: Page):
"""Parse the popup grid → [(grade_name, grade_id, shape, row_idx, has_btn)]."""
headers = await page.eval_on_selector_all(
f"#{ID['popup_grid']} tr.DataHeader th",
"els => els.map(el => el.textContent.trim())",
)
log.info(f" Popup columns: {headers}")
rows = await page.query_selector_all(
f"#{ID['popup_grid']} tr.griditemP, #{ID['popup_grid']} tr.gridaltItemP"
)
combos = []
for row_idx, row in enumerate(rows):
first_td = await row.query_selector("td[gpid]")
if not first_td:
continue
gid = (await first_td.get_attribute("gpid") or "").strip()
gname = (await first_td.get_attribute("gpname") or "").strip()
tds = await row.query_selector_all("td")
for col_idx, td in enumerate(tds):
if col_idx == 0:
continue
shape = headers[col_idx] if col_idx < len(headers) else ""
img = await td.query_selector("img[src*='blue_button']")
combos.append((gname, gid, shape, row_idx, img is not None))
active = sum(1 for c in combos if c[4])
log.info(f" {active} active grade/shape combos")
return combos
async def click_shape(page: Page, shape: str, row_idx: int) -> bool:
"""Click a shape button in the popup grid; wait for dims panel."""
arg = f"{shape}${row_idx}"
link = await page.query_selector(
f"#{ID['popup_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
)
if not link:
try:
await do_postback(page, PB["popup_grid"], arg)
except Exception:
log.warning(f" Could not click shape {arg}")
return False
else:
parent = await link.evaluate_handle("el => el.parentElement")
await parent.as_element().click()
try:
# Wait for the DimA dropdown to appear (the real indicator of dims panel loaded)
await page.wait_for_selector(f"#{ID['dim_a']}", state="attached", timeout=TIMEOUT)
await wait_for_update(page)
return True
except PwTimeout:
# Check if panel has any content at all
html = await page.inner_html(f"#{ID['dims_panel']}")
if len(html.strip()) > 50:
await wait_for_update(page)
return True
log.warning(f" Dims panel timeout for {arg}")
return False
async def click_back(page: Page):
"""Click Back to return to the popup grid view."""
try:
await do_postback(page, PB["back_btn"], "")
await wait_for_update(page)
await asyncio.sleep(DELAY)
except Exception as e:
log.warning(f" Back button error: {e}")
async def close_popup(page: Page):
"""Close the popup window and return to the main grid."""
try:
await do_postback(page, PB["popup"], "Close")
await wait_for_update(page)
await asyncio.sleep(DELAY)
except Exception as e:
log.warning(f" Close popup error: {e}")
# ═══════════════════════════════════════════════════════════════════════
# Level 3 — Dimension Panel Scraping
# ═══════════════════════════════════════════════════════════════════════
async def get_select_options(page: Page, sel_id: str):
"""Return [(value, text), ...] for a <select>, excluding placeholders."""
el = await page.query_selector(f"#{sel_id}")
if not el:
return []
# Check if disabled
disabled = await el.get_attribute("disabled")
if disabled:
return []
try:
opts = await page.eval_on_selector(
f"#{sel_id}",
"""el => Array.from(el.options).map(o => ({
v: o.value, t: o.text.trim(), d: o.disabled
}))""",
)
except Exception:
return []
return [
(o["v"], o["t"])
for o in opts
if o["v"] and o["v"] != "-1" and o["t"] and not o["d"]
and o["t"].lower() not in ("- select -", "--select--", "select...", "select", "")
]
async def scrape_dims_panel(page: Page, grade: str, shape_alro: str,
shape_mapped: str, *, save_discovery: bool = False,
on_item=None, scraped_dim_a: set[str] | None = None):
"""Main Level 3 extraction. Returns list of raw item dicts.
If on_item callback is provided, it is called with each item dict
as soon as it is discovered (for incremental saving).
If scraped_dim_a is provided, DimA values in that set are skipped (resume).
"""
items: list[dict] = []
if save_discovery:
SCREENSHOTS_DIR.mkdir(exist_ok=True)
safe = f"{grade}_{shape_alro}".replace(" ", "_").replace("/", "-")
await page.screenshot(path=str(SCREENSHOTS_DIR / f"dims_{safe}.png"), full_page=True)
html = await page.inner_html(f"#{ID['dims_panel']}")
(SCREENSHOTS_DIR / f"dims_{safe}.html").write_text(html, encoding="utf-8")
log.info(f" Discovery saved → screenshots/dims_{safe}.*")
# ── Get DimA options (primary dimension: diameter, width, size, etc.) ──
dim_a_opts = await get_select_options(page, ID["dim_a"])
if not dim_a_opts:
log.warning(f" No DimA options found")
try:
html = await page.inner_html(f"#{ID['dims_panel']}")
if len(html) > 50:
SCREENSHOTS_DIR.mkdir(exist_ok=True)
safe = f"{grade}_{shape_alro}_nodimopts".replace(" ", "_").replace("/", "-")
(SCREENSHOTS_DIR / f"{safe}.html").write_text(html, encoding="utf-8")
except Exception as e:
log.warning(f" Could not dump dims panel: {e}")
return []
already_done = scraped_dim_a or set()
remaining = [(v, t) for v, t in dim_a_opts if v not in already_done]
if already_done:
log.info(f" DimA: {len(dim_a_opts)} sizes ({len(dim_a_opts) - len(remaining)} already scraped, {len(remaining)} remaining)")
else:
log.info(f" DimA: {len(dim_a_opts)} sizes")
# All DimA values already scraped — combo is complete
if not remaining:
return []
for a_val, a_text in remaining:
# Select DimA → triggers postback → DimB/Length populate
await page.select_option(f"#{ID['dim_a']}", a_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
# Check if DimB appeared (secondary dimension: thickness, wall, etc.)
dim_b_opts = await get_select_options(page, ID["dim_b"])
if dim_b_opts:
for b_val, b_text in dim_b_opts:
await page.select_option(f"#{ID['dim_b']}", b_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
# Check for DimC (tertiary — rare)
dim_c_opts = await get_select_options(page, ID["dim_c"])
if dim_c_opts:
for c_val, c_text in dim_c_opts:
await page.select_option(f"#{ID['dim_c']}", c_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, b_val, b_text, c_val, c_text,
l_text,
)
items.append(item)
if on_item:
on_item(item)
else:
# No DimC — read lengths
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, b_val, b_text, None, None,
l_text,
)
items.append(item)
if on_item:
on_item(item)
else:
# No DimB — just DimA + Length
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, None, None, None, None,
l_text,
)
items.append(item)
if on_item:
on_item(item)
return items
def _make_item(grade, shape, a_val, a_text, b_val, b_text, c_val, c_text, l_text):
"""Build a raw item dict from dimension selections."""
return {
"grade": grade,
"shape": shape,
"dim_a_val": a_val, # decimal string like ".500"
"dim_a_text": a_text, # fraction string like "1/2"
"dim_b_val": b_val,
"dim_b_text": b_text,
"dim_c_val": c_val,
"dim_c_text": c_text,
"length_text": l_text,
"length_inches": parse_length_to_inches(l_text),
}
# ═══════════════════════════════════════════════════════════════════════
# Output — build catalog JSON
# ═══════════════════════════════════════════════════════════════════════
def build_size_and_dims(shape: str, item: dict):
"""Return (size_string, dimensions_dict) for a catalog material entry.
Uses the decimal values from dropdown option values for precision,
and fraction text from dropdown option text for display.
"""
# Use the numeric value from the dropdown (e.g. ".500") for precision
a = float(item["dim_a_val"]) if item.get("dim_a_val") else None
b = float(item["dim_b_val"]) if item.get("dim_b_val") else None
c = float(item["dim_c_val"]) if item.get("dim_c_val") else None
a_txt = normalize_dim_text(item.get("dim_a_text") or "")
b_txt = normalize_dim_text(item.get("dim_b_text") or "")
c_txt = normalize_dim_text(item.get("dim_c_text") or "")
if shape == "RoundBar" and a is not None:
return f'{a_txt}"', {"diameter": round(a, 4)}
if shape == "FlatBar":
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"width": round(a, 4), "thickness": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"width": round(a, 4), "thickness": 0}
if shape == "SquareBar" and a is not None:
return f'{a_txt}"', {"sideLength": round(a, 4)}
if shape == "Angle":
if a is not None and b is not None:
return (f'{a_txt}" x {a_txt}" x {b_txt}"',
{"leg1": round(a, 4), "leg2": round(a, 4), "thickness": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"leg1": round(a, 4), "leg2": round(a, 4), "thickness": 0}
if shape == "Channel":
# Channels may use DimA for combined designation or height
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"height": round(a, 4), "flange": round(b, 4), "web": 0})
if a is not None:
return a_txt, {"height": round(a, 4), "flange": 0, "web": 0}
if shape == "IBeam":
# DimA might be the W-designation, DimB the weight/ft
if a is not None and b is not None:
return (f"W{int(a)} x {b}",
{"height": round(a, 4), "weightPerFoot": round(b, 4)})
if a is not None:
return f"W{int(a)}", {"height": round(a, 4), "weightPerFoot": 0}
if shape == "SquareTube":
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}" wall',
{"sideLength": round(a, 4), "wall": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"sideLength": round(a, 4), "wall": 0}
if shape == "RectangularTube":
if a is not None and b is not None and c is not None:
return (f'{a_txt}" x {b_txt}" x {c_txt}" wall',
{"width": round(a, 4), "height": round(b, 4), "wall": round(c, 4)})
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"width": round(a, 4), "height": round(b, 4), "wall": 0})
if shape == "RoundTube":
if a is not None and b is not None:
return (f'{a_txt}" OD x {b_txt}" wall',
{"outerDiameter": round(a, 4), "wall": round(b, 4)})
if a is not None:
return f'{a_txt}" OD', {"outerDiameter": round(a, 4), "wall": 0}
if shape == "Pipe":
sched = b_txt or c_txt or "40"
if a is not None:
return (f'{a_txt}" NPS Sch {sched}',
{"nominalSize": round(a, 4), "schedule": sched})
# Fallback
return a_txt or "", {}
SHAPE_GROUP_KEY = {
"Angle": "angles",
"Channel": "channels",
"FlatBar": "flatBars",
"IBeam": "iBeams",
"Pipe": "pipes",
"RectangularTube": "rectangularTubes",
"RoundBar": "roundBars",
"RoundTube": "roundTubes",
"SquareBar": "squareBars",
"SquareTube": "squareTubes",
}
def build_catalog(scraped: list[dict]) -> dict:
"""Assemble the final catalog JSON from scraped item dicts."""
materials: dict[tuple, dict] = {}
for item in scraped:
shape = item.get("shape", "")
grade = item.get("grade", "")
if not shape or not grade:
continue
size_str, dims = build_size_and_dims(shape, item)
key = (shape, grade, size_str)
if key not in materials:
mat = {
"type": "Steel",
"grade": grade,
"size": size_str,
"stockItems": [],
}
mat.update(dims)
materials[key] = mat
length = item.get("length_inches")
if length and length > 0:
existing = {si["lengthInches"] for si in materials[key]["stockItems"]}
if round(length, 4) not in existing:
materials[key]["stockItems"].append({
"lengthInches": round(length, 4),
"quantityOnHand": 0,
"supplierOfferings": [{
"supplierName": "Alro Steel",
"partNumber": "",
"supplierDescription": "",
}],
})
# Group by shape key
grouped: dict[str, list] = {v: [] for v in SHAPE_GROUP_KEY.values()}
for (shape, _, _), mat in sorted(materials.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])):
group_key = SHAPE_GROUP_KEY.get(shape)
if group_key:
grouped[group_key].append(mat)
return {
"exportedAt": datetime.now(timezone.utc).isoformat(),
"suppliers": [{"name": "Alro Steel"}],
"cuttingTools": [
{"name": "Bandsaw", "kerfInches": 0.0625, "isDefault": True},
{"name": "Chop Saw", "kerfInches": 0.125, "isDefault": False},
{"name": "Cold Cut Saw", "kerfInches": 0.0625, "isDefault": False},
{"name": "Hacksaw", "kerfInches": 0.0625, "isDefault": False},
],
"materials": grouped,
}
# ═══════════════════════════════════════════════════════════════════════
# Progress management
# ═══════════════════════════════════════════════════════════════════════
def load_progress() -> dict:
if PROGRESS_PATH.exists():
return json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
return {"completed": [], "items": []}
def save_progress(progress: dict):
PROGRESS_PATH.write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8")
# ═══════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════
async def main():
discover = "--discover" in sys.argv
fresh = "--fresh" in sys.argv
all_grades = "--all-grades" in sys.argv
progress = {"completed": [], "items": []} if fresh else load_progress()
all_items: list[dict] = progress.get("items", [])
done_keys: set[tuple] = {tuple(k) for k in progress.get("completed", [])}
# Build index of saved DimA values per (grade, shape) for partial resume
saved_dim_a: dict[tuple[str, str], set[str]] = {}
if all_items and not fresh:
for item in all_items:
key = (item.get("grade", ""), item.get("shape", ""))
saved_dim_a.setdefault(key, set()).add(item.get("dim_a_val", ""))
log.info("Alro Steel SmartGrid Scraper")
if all_grades:
log.info(" Mode: ALL grades")
else:
log.info(f" Filtering to {len(GRADE_FILTER)} grades: {', '.join(sorted(GRADE_FILTER))}")
if fresh:
log.info(" Fresh start — ignoring saved progress")
elif done_keys:
log.info(f" Resuming: {len(done_keys)} combos done, {len(all_items)} items saved")
if discover:
log.info(" Discovery mode — will scrape first item then stop")
async with Stealth().use_async(async_playwright()) as pw:
browser = await pw.chromium.launch(headless=False)
ctx = await browser.new_context(
viewport={"width": 1280, "height": 900},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
locale="en-US",
timezone_id="America/Indiana/Indianapolis",
)
page = await ctx.new_page()
log.info(f"Navigating to SmartGrid …")
await page.goto(BASE_URL, wait_until="networkidle", timeout=30_000)
await asyncio.sleep(2)
if not await page.query_selector(f"#{ID['main_grid']}"):
log.error("Main grid not found! Saving screenshot.")
SCREENSHOTS_DIR.mkdir(exist_ok=True)
await page.screenshot(path=str(SCREENSHOTS_DIR / "error_no_grid.png"))
await browser.close()
return
log.info("Main grid loaded")
total_scraped = 0
first_item = True
for category in CATEGORIES:
log.info(f"\n{'=' * 60}")
log.info(f" Category: {category}")
log.info(f"{'=' * 60}")
if not await click_category(page, category):
continue
await asyncio.sleep(DELAY)
combos = await scrape_popup_grid(page)
for grade_name, grade_id, shape_name, row_idx, has_btn in combos:
if not has_btn:
continue
# Grade filter
if not all_grades and grade_name not in GRADE_FILTER:
continue
shape_upper = shape_name.upper().strip()
shape_mapped = SHAPE_MAP.get(shape_upper)
if shape_mapped is None:
log.info(f" Skip unmapped shape: {shape_name}")
continue
combo_key = (category, grade_name, shape_name)
if combo_key in done_keys:
log.info(f" Skip (done): {grade_name} / {shape_name}")
continue
log.info(f"\n -- {grade_name} / {shape_name} -> {shape_mapped} --")
if not await click_shape(page, shape_name, row_idx):
await click_back(page)
await asyncio.sleep(DELAY)
continue
await asyncio.sleep(DELAY)
combo_count = 0
def on_item_discovered(item):
nonlocal total_scraped, combo_count
all_items.append(item)
total_scraped += 1
combo_count += 1
progress["items"] = all_items
save_progress(progress)
# Pass already-scraped DimA values so partial combos resume correctly
already = saved_dim_a.get((grade_name, shape_mapped), set())
items = await scrape_dims_panel(
page, grade_name, shape_name, shape_mapped,
save_discovery=first_item or discover,
on_item=on_item_discovered,
scraped_dim_a=already,
)
first_item = False
log.info(f" -> {combo_count} items (total {total_scraped})")
done_keys.add(combo_key)
progress["completed"] = [list(k) for k in done_keys]
save_progress(progress)
await click_back(page)
await asyncio.sleep(DELAY)
if discover:
log.info("\nDiscovery done. Check: scripts/AlroCatalog/screenshots/")
await browser.close()
return
await close_popup(page)
await asyncio.sleep(DELAY)
await browser.close()
# ── Build output ──
log.info(f"\n{'=' * 60}")
log.info(f"Building catalog from {len(all_items)} items …")
catalog = build_catalog(all_items)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
log.info(f"Written: {OUTPUT_PATH}")
total_mats = sum(len(v) for v in catalog["materials"].values())
total_stock = sum(len(m["stockItems"]) for v in catalog["materials"].values() for m in v)
log.info(f"Materials: {total_mats}")
log.info(f"Stock items: {total_stock}")
for shape_key, mats in sorted(catalog["materials"].items()):
if mats:
log.info(f" {shape_key}: {len(mats)}")
if __name__ == "__main__":
asyncio.run(main())
+47
View File
@@ -0,0 +1,47 @@
"""Focused regression checks for split part-number and length cut badges."""
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
class CutBadgeFormatTests(unittest.TestCase):
def test_named_cut_badge_renders_part_number_and_length_in_separate_sections(self) -> None:
markup = JOB_EDITOR.read_text()
results_start = markup.index("private RenderFragment RenderResultsTab()")
cuts_cell_start = markup.index("@foreach (var item in entry.Bin.Items)", results_start)
cut_rows_start = markup.rfind("<td>", results_start, cuts_cell_start)
cut_rows = markup[cut_rows_start:markup.index("</td>", cuts_cell_start)]
self.assertIn('class="badge me-1 mb-1 fs-6 cut-part-badge"', cut_rows)
self.assertIn('class="cut-part-badge-name"', cut_rows)
self.assertIn('class="cut-part-badge-length"', cut_rows)
self.assertIn('@item.Name', cut_rows)
self.assertIn('@FormatResultLength(item.Length)', cut_rows)
self.assertNotIn('$"{item.Name} (', cut_rows)
def test_length_section_has_a_darker_background_than_the_part_number_section(self) -> None:
css = APP_CSS.read_text()
self.assertIn('.cut-part-badge {', css)
self.assertIn('.cut-part-badge-name {', css)
self.assertIn('.cut-part-badge-length {', css)
self.assertIn('background-color: #4c6680;', css)
def test_printed_cut_badges_keep_part_numbers_and_separators_legible(self) -> None:
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
print_styles = report_css[report_css.index("@media print {"):]
self.assertIn(".cut-part-badge {", print_styles)
self.assertIn("color: #000 !important;", print_styles)
self.assertIn(".cut-part-badge-divider {", print_styles)
self.assertIn("border-left: 1px solid #000 !important;", print_styles)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
"""Regression checks for compact CutList identifiers."""
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
JOB_SERVICE = REPO_ROOT / "CutList.Web/Services/JobService.cs"
UNPAD_MIGRATION = REPO_ROOT / "CutList.Web/Migrations/20260802212500_RemoveJobNumberPadding.cs"
class JobNumberFormatTests(unittest.TestCase):
def test_new_jobs_use_the_database_id_without_zero_padding(self) -> None:
service = JOB_SERVICE.read_text()
self.assertIn('job.JobNumber = $"JOB-{job.Id}";', service)
self.assertNotIn("D5", service)
self.assertNotIn("MaxAsync() as string", service)
def test_existing_job_numbers_are_converted_to_their_database_ids(self) -> None:
migration = UNPAD_MIGRATION.read_text()
self.assertIn("SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))", migration)
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
"""Focused regression checks for job-level actions on the Results tab."""
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
class JobResultsActionsTests(unittest.TestCase):
def test_lock_job_is_in_the_results_action_row_before_result_cards(self) -> None:
markup = JOB_EDITOR.read_text()
results_start = markup.index("private RenderFragment RenderResultsTab()")
result_cards_start = markup.index("@if (packResult != null && summary != null)", results_start)
action_row = markup[results_start:result_cards_start]
self.assertIn('@onclick="PrintReport"', action_row)
self.assertIn('@onclick="LockJob"', action_row)
self.assertIn("Lock Job", action_row)
def test_print_report_hides_job_lock_status(self) -> None:
markup = JOB_EDITOR.read_text()
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
self.assertIn('class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only"', markup)
self.assertIn('<span class="badge bg-success print-screen-only">', markup)
self.assertIn(".print-screen-only", report_css)
self.assertIn("display: none !important;", report_css)
def test_print_report_forces_backgrounds_to_plain_white(self) -> None:
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
print_styles = report_css[report_css.index("@media print {"):]
self.assertIn("*::before", print_styles)
self.assertIn("background: transparent !important;", print_styles)
self.assertIn("box-shadow: none !important;", print_styles)
def test_print_report_shows_the_selected_cutting_method_and_kerf(self) -> None:
markup = JOB_EDITOR.read_text()
results_start = markup.index("private RenderFragment RenderResultsTab()")
summary_start = markup.index('<div class="row mb-4 print-summary">', results_start)
material_list_start = markup.index('<!-- Material List:', summary_start)
report_summary = markup[summary_start:material_list_start]
self.assertIn('class="print-cut-method"', report_summary)
self.assertIn("<strong>Cut Method:</strong>", report_summary)
self.assertIn("@job.CuttingTool.Name", report_summary)
self.assertIn("Kerf:", report_summary)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
"""Regression checks for the CutList navigation brand on its light header."""
from __future__ import annotations
import re
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
NAV_MENU = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor"
NAV_MENU_CSS = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor.css"
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
APP_SHELL = REPO_ROOT / "CutList.Web/Components/App.razor"
def selector_color(css: str, selector: str) -> str:
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css)
if match is None:
raise AssertionError(f"Missing CSS selector: {selector}")
color = re.search(r"\bcolor:\s*(#[0-9a-fA-F]{6})", match.group(1))
if color is None:
raise AssertionError(f"Missing hex color for selector: {selector}")
return color.group(1)
def contrast_ratio(foreground: str, background: str) -> float:
def luminance(color: str) -> float:
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
return (lighter + 0.05) / (darker + 0.05)
class NavMenuBrandContrastTests(unittest.TestCase):
def test_list_word_has_accessible_contrast_against_light_top_row(self) -> None:
list_color = selector_color(NAV_MENU_CSS.read_text(), ".navbar-brand .brand-list")
top_row_color = selector_color(APP_CSS.read_text(), ".top-row")
self.assertGreaterEqual(contrast_ratio(list_color, top_row_color), 4.5)
def test_brand_keeps_cut_and_list_as_adjacent_words(self) -> None:
markup = NAV_MENU.read_text()
self.assertIn('<span class="brand-cut">Cut</span><span class="brand-list">List</span>', markup)
def test_visual_system_uses_the_industrial_blue_accent(self) -> None:
css = APP_CSS.read_text()
self.assertIn("--accent: #2b75a8;", css)
self.assertIn("--navy: #203747;", css)
self.assertNotIn("--orange:", css)
def test_desktop_content_padding_does_not_push_the_nav_brand_right(self) -> None:
css = APP_CSS.read_text()
self.assertNotIn(".top-row, article {", css)
def test_nav_brand_is_left_aligned_with_the_navigation_items(self) -> None:
css = NAV_MENU_CSS.read_text()
self.assertIn(".top-row { justify-content: flex-start;", css)
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
shell = APP_SHELL.read_text()
css = APP_CSS.read_text()
self.assertIn("family=IBM+Plex+Sans", shell)
self.assertIn('font-family: "IBM Plex Sans",', css)
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""Focused regression checks for the data-backed CutList overview."""
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
class OverviewDashboardTests(unittest.TestCase):
def test_overview_uses_a_dedicated_data_service(self) -> None:
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
source = OVERVIEW_SERVICE.read_text()
self.assertIn("GetSnapshotAsync", source)
self.assertIn("context.Jobs", source)
self.assertIn("context.JobStocks", source)
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
markup = HOME_PAGE.read_text()
self.assertIn("@inject OverviewService OverviewService", markup)
self.assertIn("Recently created jobs", markup)
self.assertIn("Frequently specified stock", markup)
self.assertIn("Based on stock configured on jobs", markup)
self.assertIn("overview.RecentJobs", markup)
self.assertIn("overview.FrequentStock", markup)
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
source = OVERVIEW_SERVICE.read_text()
self.assertIn(
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
source,
)
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
css = APP_CSS.read_text()
self.assertIn(".overview-grid", css)
self.assertIn(".overview-stock-list", css)
self.assertIn(".overview-recent-jobs", css)
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
markup = HOME_PAGE.read_text()
self.assertIn("loadError", markup)
self.assertIn("Overview data is unavailable", markup)
self.assertIn("catch (Exception ex)", markup)
if __name__ == "__main__":
unittest.main()