Compare commits

...
55 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
aj 7b210f518b docs: remove last stale StockItemService pricing reference; ignore playwright-mcp logs
Build CutList image / build-and-push (push) Successful in 1m11s
CLAUDE.md still listed GetAverageCostAsync/GetLastPurchasePriceAsync, which
Task 1 of the vendor-data removal already deleted. Also stop tracking the
Playwright MCP browser session logs left behind by Task 10's smoke test.
2026-08-01 09:33:53 -04:00
aj 3244e15593 fix: address final review findings in vendor-data removal (catalog save, job locking, DB collision, docs) 2026-08-01 09:32:16 -04:00
aj b77d0d971d feat: remove supplier MCP tools, add plain add_stock convenience tool 2026-08-01 08:55:49 -04:00
aj f75c205bc5 chore: strip vendor data from oneals-catalog.json seed file 2026-08-01 08:52:25 -04:00
aj 1b4d5f76a8 feat: remove Supplier/SupplierOffering/PurchaseItem entities and migrate schema 2026-08-01 08:49:05 -04:00
ajandClaude Sonnet 5 41944638c6 feat: drop supplier import/export from the catalog format
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 08:45:21 -04:00
aj 063c844310 feat: delete SupplierService, PurchaseItemService, and their API surface 2026-08-01 08:41:28 -04:00
aj 597181bd78 feat: replace Add to Order List with a direct Lock Job action 2026-08-01 08:38:20 -04:00
aj 8d6ada35e5 feat: remove Suppliers and Orders pages and nav entries 2026-08-01 08:34:47 -04:00
aj b51d936b78 fix: make stock item length editable and drop vendor UI from Stock Edit page 2026-08-01 08:31:40 -04:00
aj e7248be9a4 refactor: strip supplier/pricing from StockItemService and stock-items API 2026-08-01 08:27:55 -04:00
aj 06f0c2032d chore: ignore .superpowers/ SDD scratch workspace directory
Keeps subagent-driven-development's per-plan ledger/briefs/reports out
of version control.
2026-08-01 08:24:47 -04:00
aj 9296bea0bc docs: add implementation plan for removing vendor/supplier data
Ten-task plan ordered so the build stays green at each step: strip
consumers first (StockItemService/DTOs/Controller, Stock/Edit.razor,
Suppliers/Orders pages, Jobs/Edit.razor, CatalogService), delete the
now-unused services, then remove the entities and migrate the schema
last. Closes with CutList.Mcp cleanup and a manual smoke test.
2026-07-31 22:56:41 -04:00
aj 4e874df22f docs: add design spec for removing vendor/supplier data from CutList
Scopes out Supplier, SupplierOffering, PurchaseItem, and cost tracking
as out-of-scope for a 1D nesting optimizer, while fixing the actual
reported bug (read-only stock length field) and keeping a stripped
quantity-only transaction log.
2026-07-31 22:44:13 -04:00
ajandClaude Sonnet 5 7574476d7f feat: add Docker deployment for CutList.Web and update CLAUDE.md docs
Adds a Dockerfile and Gitea Actions workflow to build/push the
CutList.Web image to the registry on push to master, enabling
containerized deployment to forge. Also updates CLAUDE.md to reflect
the CutList.Mcp project, REST API surface, and CatalogService, and
tweaks the cut-length badge styling in the job editor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 18:50:06 -04:00
70 changed files with 12031 additions and 14794 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Build CutList image
on:
push:
branches: [master]
paths:
- "CutList.Web/**"
- "CutList.Core/**"
- "CutList.Web/Dockerfile"
- ".gitea/workflows/build-cutlist.yml"
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
- name: Build image
run: docker build -f CutList.Web/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/cutlist:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/cutlist:${{ gitea.sha }} .
- name: Push image
run: |
docker push git.thecozycat.net/${{ gitea.repository_owner }}/cutlist:latest
docker push git.thecozycat.net/${{ gitea.repository_owner }}/cutlist:${{ gitea.sha }}
+11 -1
View File
@@ -242,4 +242,14 @@ ModelManifest.xml
.paket/paket.exe
# FAKE - F# Make
.fake/
.fake/
# Superpowers subagent-driven-development scratch workspace
.superpowers/
# Playwright MCP browser session logs/traces
.playwright-mcp/
# Python bytecode cache (tests/)
__pycache__/
*.pyc
+69 -48
View File
@@ -8,15 +8,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
CutList is a 1D bin packing optimization application that helps users optimize material cutting. It calculates efficient bin packing solutions to minimize waste when cutting stock materials into required parts.
The solution contains three projects:
The solution contains four projects:
| Project | Framework | Purpose |
|---------|-----------|---------|
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI with EF Core + SQL Server |
| **CutList** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
| **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
| **CutList.Web** | .NET 10.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI)
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
## Build Commands
@@ -28,10 +29,11 @@ dotnet build CutList.sln
dotnet build CutList/CutList.csproj
dotnet build CutList.Core/CutList.Core.csproj
dotnet build CutList.Web/CutList.Web.csproj
dotnet build CutList.Mcp/CutList.Mcp.csproj
# Run applications
dotnet run --project CutList/CutList.csproj # WinForms
dotnet run --project CutList.Web/CutList.Web.csproj # Blazor
dotnet run --project CutList.Web/CutList.Web.csproj # Blazor + REST API (default http://localhost:5270)
# EF Core migrations (always apply immediately after creating)
dotnet ef migrations add <Name> --project CutList.Web
@@ -41,6 +43,18 @@ dotnet ef database update --project CutList.Web
dotnet clean CutList.sln
```
### Deploying CutList.Web as a Windows Service
```powershell
powershell -ExecutionPolicy Bypass -File scripts/Deploy-CutListWeb.ps1 -ServiceName CutListWeb -InstallDir C:\Services\CutListWeb -Urls "http://*:5270" -OpenFirewall
```
Publishes, (re)creates the `CutListWeb` Windows service with auto-restart recovery, and optionally opens the firewall port. See `docs/deploy-script-guide.md` (global docs) for the template this follows.
### Publishing CutList.Mcp
CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `~/.claude/mcp/CutList.Mcp/` and registered in `~/.claude/settings.local.json` (see global `CLAUDE.md` MCP Server Publishing table). It talks to CutList.Web's REST API at `http://localhost:5270`, so CutList.Web must be running (dev `dotnet run` or the deployed Windows service) for the MCP tools to work.
## Architecture
### CutList.Core — Domain & Algorithms
@@ -73,11 +87,33 @@ dotnet clean CutList.sln
- `CutListService` bridges UI models to core packing algorithms
- `DocumentService` handles JSON file persistence
### CutList.Web (Blazor Server) — Web UI
### CutList.Web (Blazor Server) — Web UI + REST API
**Database**: SQL Server via Entity Framework Core (connection string: `DefaultConnection`)
**Service Registration** (Program.cs): All services registered as Scoped — MaterialService, SupplierService, StockItemService, JobService, CutListPackingService, ReportService, PurchaseItemService
**Service Registration** (Program.cs): All services registered as Scoped — MaterialService, StockItemService, JobService, CutListPackingService, ReportService, CatalogService. `IDbContextFactory<ApplicationDbContext>` is used (not a scoped `DbContext` directly) for Blazor Server circuit safety.
**REST API** (`Controllers/`): `JobsController`, `MaterialsController`, `StockItemsController`, `CuttingToolsController`, `PackingController`, `CatalogController` — Swagger/OpenAPI enabled in Development. This API is the integration surface `CutList.Mcp` calls into; the Blazor UI talks to the services directly and does not go through it.
**Error handling**: `UseExceptionHandler("/Error", ...)` in non-Development environments routes to `Components/Pages/Error.razor`.
**Table actions**: Every row-level action cell uses the shared `table-actions` class from `wwwroot/css/app.css`. It is an `inline-flex` non-wrapping control group, so Edit/Delete/Copy buttons remain side by side and do not increase table-row height.
**Job title**: The job-editor page uses the `job-title` class to keep long job names at a compact, readable heading size without changing the larger dashboard hero typography.
**Overview**: The root page uses `OverviewService` to show recently created jobs, headline planning counts, and the five stock configurations most frequently specified across job stock. The stock ranking is a planning-demand signal (distinct jobs configured for a material/length), not a count of on-hand inventory.
**Material list semantics**: The Results tab labels lengths not covered by the job's configured stock as a **Material List**, not a purchase list. It identifies required material; purchasing remains a separate decision outside the cut-list result.
### CutList.Mcp — MCP Server
Stdio-transport MCP server (`ModelContextProtocol` SDK) exposing CutList.Web's REST API as tools for Claude Code. Registers tools via `WithToolsFromAssembly`; logging is disabled entirely so it doesn't interfere with the stdio transport.
- `ApiClient.cs` — typed `HttpClient` wrapper for CutList.Web's REST API (`BaseAddress` hardcoded to `http://localhost:5270`)
- `JobTools.cs` — job CRUD, parts/stock, optimization (`OptimizeJob`), cutting tools
- `InventoryTools.cs` — materials, stock items (`add_stock`, etc.)
- `CutListTools.cs` — static helpers shared across tool classes
- `Models.cs` — shared DTOs distinct from CutList.Web's own DTOs (kept intentionally thin for MCP tool responses)
## CutList.Web Entities
@@ -92,21 +128,10 @@ dotnet clean CutList.sln
Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its own standalone table (`DimAngle`, `DimChannel`, `DimFlatBar`, `DimIBeam`, `DimPipe`, `DimRectangularTube`, `DimRoundBar`, `DimRoundTube`, `DimSquareBar`, `DimSquareTube`) with no base table. Each table has its own `Id` (shared sequence) and `MaterialId` FK. Each generates its own `SizeString` and `SortOrder`.
### StockItem
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
- `MaterialId`, `LengthInches` (decimal), `IsActive`
- **Unique constraint**: (MaterialId, LengthInches)
- **Relationships**: `Material`, `SupplierOfferings` (1:many), `Transactions` (1:many StockTransaction)
### StockTransaction
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
- Optional: `JobId`, `SupplierId`, `UnitPrice`
### Supplier
- `Name` (required), `ContactInfo`, `Notes`, `IsActive`
- **Relationships**: `Offerings` (1:many SupplierOffering)
### SupplierOffering
- Links Supplier to StockItem with optional `PartNumber`, `Price`, `Notes`
- **Unique constraint**: (SupplierId, StockItemId)
- **Relationships**: `Material`
- No quantity tracking — a StockItem represents a length of material you can cut from, not a counted inventory record
### CuttingTool
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
@@ -125,9 +150,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
### JobStock
- `JobId`, `MaterialId`, `StockItemId?`, `LengthInches`, `Quantity` (-1 = unlimited), `IsCustomLength`, `Priority` (lower = used first), `SortOrder`
### PurchaseItem
- `StockItemId`, `SupplierId?`, `JobId?`, `Quantity`, `Status` (Pending/Ordered/Received), `Notes`
## CutList.Web Services
### MaterialService
@@ -137,17 +159,10 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
### StockItemService
- CRUD with soft delete
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
- Pricing: `GetAverageCostAsync`, `GetLastPurchasePriceAsync`
### SupplierService
- CRUD for suppliers and offerings
- `GetOfferingsForStockItemAsync` — all supplier options for a stock item
### JobService
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
- Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability after ordering
- Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability
- Parts: `AddPartAsync`, `UpdatePartAsync`, `DeletePartAsync` (all update job timestamp + clear optimization results)
- Stock: `AddStockAsync`, `UpdateStockAsync`, `DeleteStockAsync` (all clear optimization results)
- Optimization: `SaveOptimizationResultAsync`, `ClearOptimizationResultAsync`
@@ -155,17 +170,17 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
### CutListPackingService
- `PackAsync(parts, kerfInches, jobStock?)` — runs optimization per material group
- Separates results into `InStockBins` (from inventory) and `ToBePurchasedBins`
- Separates results into `InStockBins` (from catalog-sourced job stock) and `ToBePurchasedBins`
- `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.)
### PurchaseItemService
- CRUD + `CreateBulkAsync` for batch creation from optimization results
- `UpdateStatusAsync(id, status)`, `UpdateSupplierAsync(id, supplierId)`
### ReportService
- `FormatLength(inches)`, `GroupItems(items)` for print report formatting
### CatalogService
- `ExportAsync()` — dumps cutting tools and materials (with dimensions + stock items) into a shape-grouped `CatalogData` DTO for bulk export/import tooling
- Backs the `CatalogController` REST endpoint and the `scripts/ExportData` data-loading workflow
## CutList.Web Pages
| Route | Page | Purpose |
@@ -176,13 +191,10 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
| `/jobs/{Id}` | Jobs/Edit | Tabbed editor (Details, Parts, Stock, Results); locked jobs show banner + disable editing |
| `/materials` | Materials/Index | Material list with MaterialFilter, pagination |
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) |
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
| `/stock` | Stock/Index | Stock items with MaterialFilter, pagination |
| `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
| `/orders` | Orders/Index | Tabbed (Pending/Ordered/All), supplier assignment, status transitions |
| `/orders/add` | Orders/Add | Manual purchase item creation |
| `/suppliers` | Suppliers/Index | Supplier list with CRUD |
| `/suppliers/{Id}` | Suppliers/Edit | Supplier + offerings management |
| `/tools` | Tools/Index | Cutting tools CRUD |
| `/Error` | Error | Unhandled exception page (registered via `UseExceptionHandler`) |
## Shared Components
@@ -191,23 +203,30 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
| `ConfirmDialog` | Modal confirmation for destructive actions (Show/Hide methods, OnConfirm callback) |
| `LengthInput` | Architectural unit input — parses "12'", "6\"", "12 1/2\""; reformats on blur; two-way binding via `Value` or `NullableValue` |
| `Pager` | Pagination with "Showing X-Y of Z", prev/next, smart page window with ellipsis |
| `MaterialFilter` | Reusable filter: Shape, Type, Grade dropdowns + search text; used on Materials, Stock, Orders pages |
| `MaterialFilter` | Reusable filter: Shape, Type, Grade dropdowns + search text; used on Materials, Stock pages |
## Key Patterns & Conventions
- **Nullable reference types enabled** — handle nulls explicitly
- **Soft deletes** — Materials, Suppliers, StockItems, CuttingTools use `IsActive` flag
- **Job locking** — `LockedAt` timestamp set when materials ordered; Edit page disables all modification via `<fieldset disabled>`, hides add/edit/delete buttons; Unlock button to re-enable editing
- **Soft deletes** — Materials, StockItems, CuttingTools use `IsActive` flag
- **Job locking** — `LockedAt` timestamp set via a manual Lock Job action (always available, regardless of whether the job needs purchases); Edit page disables all modification via `<fieldset disabled>`, hides add/edit/delete buttons; Unlock button to re-enable editing
- **Pagination** — All list pages use `Pager` with `pageSize = 25`
- **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
- **Job stock** — Jobs can use auto-discovered inventory OR define custom stock lengths
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
- **Purchase flow** — Optimize job -> "Add to Order List" creates PurchaseItems + locks job -> Orders page manages status (Pending -> Ordered -> Received)
- **Job lock flow** — Optimize job -> review/print results -> Lock Job (manual action beside Print Report on the Results tab, available whether or not purchases are needed) -> job becomes read-only until Unlock
- **Printed cut badges** — Print styles intentionally remove color fills; cut badges therefore force black text and a black part-number/length divider so both remain legible on paper. The print-only tool line shows the selected cut method and kerf immediately below the summary.
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
- **Priority system** — Lower priority bins used first in packing algorithm
- **UI ↔ MCP split** — The Blazor UI calls services directly (in-process); CutList.Mcp and any other external integration go through the REST API in `Controllers/`. Keep both paths in sync when changing service method signatures used by controllers.
## Supporting Scripts (`scripts/`)
- `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`)
## Key Files
@@ -221,4 +240,6 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
| `CutList.Web/Services/JobService.cs` | Job orchestration (CRUD, parts, stock, tools, lock/unlock) |
| `CutList.Web/Services/CutListPackingService.cs` | Bridges web entities to Core packing engine |
| `CutList.Web/Components/Pages/Jobs/Edit.razor` | Job editor (tabbed: Details, Parts, Stock, Results) |
| `CutList.Mcp/ApiClient.cs` | HTTP client the MCP server uses to call CutList.Web's REST API |
| `CutList.Mcp/Program.cs` | MCP server entrypoint (stdio transport, tool registration) |
| `CutList/Presenters/MainFormPresenter.cs` | WinForms business logic orchestrator |
@@ -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}\"";
}
}
/// <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 -79
View File
@@ -14,23 +14,6 @@ public class ApiClient
_http = http;
}
#region Suppliers
public async Task<List<ApiSupplierDto>> GetSuppliersAsync(bool includeInactive = false)
{
var url = $"api/suppliers?includeInactive={includeInactive}";
return await _http.GetFromJsonAsync<List<ApiSupplierDto>>(url) ?? [];
}
public async Task<ApiSupplierDto?> CreateSupplierAsync(string name, string? contactInfo, string? notes)
{
var response = await _http.PostAsJsonAsync("api/suppliers", new { Name = name, ContactInfo = contactInfo, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiSupplierDto>();
}
#endregion
#region Materials
public async Task<List<ApiMaterialDto>> GetMaterialsAsync(string? shape = null, bool includeInactive = false)
@@ -87,14 +70,13 @@ public class ApiClient
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? [];
}
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, int quantityOnHand, string? notes)
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, string? notes)
{
var body = new
{
MaterialId = materialId,
Length = length,
Name = name,
QuantityOnHand = quantityOnHand,
Notes = notes
};
var response = await _http.PostAsJsonAsync("api/stock-items", body);
@@ -212,40 +194,6 @@ public class ApiClient
#endregion
#region Offerings
public async Task<List<ApiOfferingDto>> GetOfferingsForSupplierAsync(int supplierId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/suppliers/{supplierId}/offerings") ?? [];
}
public async Task<List<ApiOfferingDto>> GetOfferingsForStockItemAsync(int stockItemId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/stock-items/{stockItemId}/offerings") ?? [];
}
public async Task<ApiOfferingDto?> CreateOfferingAsync(int supplierId, int stockItemId,
string? partNumber, string? supplierDescription, decimal? price, string? notes)
{
var body = new
{
StockItemId = stockItemId,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price,
Notes = notes
};
var response = await _http.PostAsJsonAsync($"api/suppliers/{supplierId}/offerings", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiOfferingDto>();
}
#endregion
}
/// <summary>
@@ -385,15 +333,6 @@ public class ApiMaterialPackingSummaryDto
#region API Response DTOs Inventory
public class ApiSupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class ApiMaterialDto
{
public int Id { get; set; }
@@ -420,23 +359,6 @@ public class ApiStockItemDto
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class ApiOfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string? SupplierName { get; set; }
public int StockItemId { get; set; }
public string? MaterialName { get; set; }
public decimal? LengthInches { get; set; }
public string? LengthFormatted { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
+22 -293
View File
@@ -5,7 +5,7 @@ using ModelContextProtocol.Server;
namespace CutList.Mcp;
/// <summary>
/// MCP tools for inventory management - suppliers, materials, stock items, and offerings.
/// MCP tools for inventory management - materials and stock items.
/// All calls go through the CutList.Web REST API via ApiClient.
/// </summary>
[McpServerToolType]
@@ -18,56 +18,6 @@ public class InventoryTools
_api = api;
}
#region Suppliers
[McpServerTool(Name = "list_suppliers"), Description("Lists all suppliers in the system.")]
public async Task<SupplierListResult> ListSuppliers(
[Description("Include inactive suppliers (default false)")]
bool includeInactive = false)
{
var suppliers = await _api.GetSuppliersAsync(includeInactive);
return new SupplierListResult
{
Success = true,
Suppliers = suppliers.Select(s => new SupplierDto
{
Id = s.Id,
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes,
IsActive = s.IsActive
}).ToList()
};
}
[McpServerTool(Name = "add_supplier"), Description("Adds a new supplier to the system.")]
public async Task<SupplierResult> AddSupplier(
[Description("Supplier name (e.g., 'O'Neal Steel')")]
string name,
[Description("Contact info - website, phone, email, etc.")]
string? contactInfo = null,
[Description("Notes about the supplier")]
string? notes = null)
{
var supplier = await _api.CreateSupplierAsync(name, contactInfo, notes);
return new SupplierResult
{
Success = true,
Supplier = supplier != null ? new SupplierDto
{
Id = supplier.Id,
Name = supplier.Name,
ContactInfo = supplier.ContactInfo,
Notes = supplier.Notes,
IsActive = supplier.IsActive
} : null
};
}
#endregion
#region Materials
[McpServerTool(Name = "list_materials"), Description("Lists all materials (shape/size combinations) in the system.")]
@@ -229,7 +179,6 @@ public class InventoryTools
LengthInches = s.LengthInches,
LengthFormatted = s.LengthFormatted,
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes,
IsActive = s.IsActive
}).ToList()
@@ -244,14 +193,12 @@ public class InventoryTools
string length,
[Description("Optional name/label for this stock item")]
string? name = null,
[Description("Initial quantity on hand (default 0)")]
int quantityOnHand = 0,
[Description("Notes")]
string? notes = null)
{
try
{
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, quantityOnHand, notes);
var stockItem = await _api.CreateStockItemAsync(materialId, length, name, notes);
if (stockItem == null)
return new StockItemResult { Success = false, Error = "Failed to create stock item" };
@@ -267,7 +214,6 @@ public class InventoryTools
LengthInches = stockItem.LengthInches,
LengthFormatted = stockItem.LengthFormatted,
Name = stockItem.Name,
QuantityOnHand = stockItem.QuantityOnHand,
Notes = stockItem.Notes,
IsActive = stockItem.IsActive
}
@@ -285,135 +231,10 @@ public class InventoryTools
#endregion
#region Supplier Offerings
#region Convenience
[McpServerTool(Name = "list_supplier_offerings"), Description("Lists supplier offerings (what suppliers sell for each stock item).")]
public async Task<SupplierOfferingListResult> ListSupplierOfferings(
[Description("Filter by supplier ID")]
int? supplierId = null,
[Description("Filter by stock item ID")]
int? stockItemId = null,
[Description("Filter by material ID")]
int? materialId = null)
{
List<ApiOfferingDto> offerings;
if (supplierId.HasValue)
{
offerings = await _api.GetOfferingsForSupplierAsync(supplierId.Value);
// Apply additional filters client-side
if (stockItemId.HasValue)
offerings = offerings.Where(o => o.StockItemId == stockItemId.Value).ToList();
if (materialId.HasValue)
{
// Need to get stock items for this material to filter
var stockItems = await _api.GetStockItemsAsync(materialId);
var stockItemIds = stockItems.Select(s => s.Id).ToHashSet();
offerings = offerings.Where(o => stockItemIds.Contains(o.StockItemId)).ToList();
}
}
else if (stockItemId.HasValue)
{
offerings = await _api.GetOfferingsForStockItemAsync(stockItemId.Value);
}
else if (materialId.HasValue)
{
// Get stock items for this material, then aggregate offerings
var stockItems = await _api.GetStockItemsAsync(materialId);
var allOfferings = new List<ApiOfferingDto>();
foreach (var si in stockItems)
{
var siOfferings = await _api.GetOfferingsForStockItemAsync(si.Id);
allOfferings.AddRange(siOfferings);
}
offerings = allOfferings;
}
else
{
// No filter - get all suppliers then aggregate
var suppliers = await _api.GetSuppliersAsync();
var allOfferings = new List<ApiOfferingDto>();
foreach (var s in suppliers)
{
var sOfferings = await _api.GetOfferingsForSupplierAsync(s.Id);
allOfferings.AddRange(sOfferings);
}
offerings = allOfferings;
}
return new SupplierOfferingListResult
{
Success = true,
Offerings = offerings.Select(o => new SupplierOfferingDto
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.SupplierName ?? string.Empty,
StockItemId = o.StockItemId,
MaterialName = o.MaterialName ?? string.Empty,
LengthFormatted = o.LengthFormatted ?? string.Empty,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes
}).ToList()
};
}
[McpServerTool(Name = "add_supplier_offering"), Description("Adds a supplier offering - links a supplier to a stock item with their part number and pricing.")]
public async Task<SupplierOfferingResult> AddSupplierOffering(
[Description("Supplier ID (use list_suppliers to find)")]
int supplierId,
[Description("Stock item ID (use list_stock_items to find)")]
int stockItemId,
[Description("Supplier's part number")]
string? partNumber = null,
[Description("Supplier's description of the item")]
string? supplierDescription = null,
[Description("Price per unit")]
decimal? price = null,
[Description("Notes")]
string? notes = null)
{
try
{
var offering = await _api.CreateOfferingAsync(supplierId, stockItemId, partNumber, supplierDescription, price, notes);
if (offering == null)
return new SupplierOfferingResult { Success = false, Error = "Failed to create offering" };
return new SupplierOfferingResult
{
Success = true,
Offering = new SupplierOfferingDto
{
Id = offering.Id,
SupplierId = offering.SupplierId,
SupplierName = offering.SupplierName ?? string.Empty,
StockItemId = offering.StockItemId,
MaterialName = offering.MaterialName ?? string.Empty,
LengthFormatted = offering.LengthFormatted ?? string.Empty,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
}
};
}
catch (ApiConflictException ex)
{
return new SupplierOfferingResult { Success = false, Error = ex.Message };
}
catch (HttpRequestException ex)
{
return new SupplierOfferingResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "add_stock_with_offering"), Description("Convenience method: adds a material (if needed), stock item (if needed), and supplier offering all at once.")]
public async Task<AddStockWithOfferingResult> AddStockWithOffering(
[Description("Supplier ID (use list_suppliers or add_supplier first)")]
int supplierId,
[McpServerTool(Name = "add_stock"), Description("Convenience method: adds a material (if needed) and a stock item (if needed), all in one call.")]
public async Task<AddStockResult> AddStock(
[Description("Material shape (e.g., 'Angle', 'FlatBar')")]
string shape,
[Description("Material size (e.g., '2 x 2 x 1/4')")]
@@ -423,13 +244,7 @@ public class InventoryTools
[Description("Material type: Steel, Aluminum, Stainless, Brass, Copper (default: Steel)")]
string type = "Steel",
[Description("Grade or specification (e.g., 'A36', 'Hot Roll', '304', '6061-T6')")]
string? grade = null,
[Description("Supplier's part number")]
string? partNumber = null,
[Description("Supplier's description")]
string? supplierDescription = null,
[Description("Price per unit")]
decimal? price = null)
string? grade = null)
{
// Parse length for formatted display
double lengthInches;
@@ -441,7 +256,7 @@ public class InventoryTools
}
catch
{
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = $"Could not parse length: {length}"
@@ -478,12 +293,12 @@ public class InventoryTools
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
return new AddStockResult { Success = false, Error = $"Failed to create material: {ex.Message}" };
}
}
if (material == null)
return new AddStockWithOfferingResult { Success = false, Error = "Failed to find or create material" };
return new AddStockResult { Success = false, Error = "Failed to find or create material" };
// Step 2: Find or create stock item
bool stockItemCreated = false;
@@ -494,7 +309,7 @@ public class InventoryTools
{
try
{
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, 0, null);
stockItem = await _api.CreateStockItemAsync(material.Id, length, null, null);
stockItemCreated = true;
}
catch (ApiConflictException)
@@ -505,7 +320,7 @@ public class InventoryTools
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = $"Failed to create stock item: {ex.Message}",
@@ -515,53 +330,23 @@ public class InventoryTools
}
if (stockItem == null)
return new AddStockWithOfferingResult
return new AddStockResult
{
Success = false,
Error = "Failed to find or create stock item",
MaterialCreated = materialCreated
};
// Step 3: Create offering
try
return new AddStockResult
{
var offering = await _api.CreateOfferingAsync(supplierId, stockItem.Id, partNumber, supplierDescription, price, null);
return new AddStockWithOfferingResult
{
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches),
OfferingId = offering?.Id ?? 0,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price
};
}
catch (ApiConflictException)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Offering for this supplier and stock item already exists",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
catch (HttpRequestException ex)
{
return new AddStockWithOfferingResult
{
Success = false,
Error = $"Failed to create offering: {ex.Message}",
MaterialCreated = materialCreated,
StockItemCreated = stockItemCreated
};
}
Success = true,
MaterialId = material.Id,
MaterialName = $"{material.Shape} - {material.Size}",
MaterialCreated = materialCreated,
StockItemId = stockItem.Id,
StockItemCreated = stockItemCreated,
LengthFormatted = ArchUnits.FormatFromInches(lengthInches)
};
}
#endregion
@@ -676,29 +461,6 @@ public class InventoryTools
#region DTOs
public class SupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class SupplierListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<SupplierDto> Suppliers { get; set; } = new();
}
public class SupplierResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public SupplierDto? Supplier { get; set; }
}
public class MaterialDimensionsDto
{
public double? Diameter { get; set; }
@@ -750,7 +512,6 @@ public class StockItemDto
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
@@ -769,35 +530,7 @@ public class StockItemResult
public StockItemDto? StockItem { get; set; }
}
public class SupplierOfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string SupplierName { get; set; } = string.Empty;
public int StockItemId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public string LengthFormatted { get; set; } = string.Empty;
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class SupplierOfferingListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<SupplierOfferingDto> Offerings { get; set; } = new();
}
public class SupplierOfferingResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public SupplierOfferingDto? Offering { get; set; }
}
public class AddStockWithOfferingResult
public class AddStockResult
{
public bool Success { get; set; }
public string? Error { get; set; }
@@ -807,10 +540,6 @@ public class AddStockWithOfferingResult
public int StockItemId { get; set; }
public bool StockItemCreated { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int OfferingId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
}
#endregion
+3 -3
View File
@@ -257,9 +257,9 @@ public class JobTools
string length,
[Description("Quantity available (-1 for unlimited, default -1)")]
int quantity = -1,
[Description("Stock item ID from inventory (optional - links to tracked inventory)")]
[Description("Stock item ID from the stock catalog (optional - links to a specific catalog stock item)")]
int? stockItemId = null,
[Description("True if this is a custom length not from inventory (default false)")]
[Description("True if this is a custom length not sourced from the stock catalog (default false)")]
bool isCustomLength = false,
[Description("Priority - lower number = used first (default 10)")]
int priority = 10)
@@ -304,7 +304,7 @@ public class JobTools
#region Optimization
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined. If stock is defined, it will be used; otherwise the optimizer uses available inventory. Returns optimized cut layouts per material with efficiency stats.")]
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined, and stock must be explicitly configured on the job (via add_job_stock) for each material used by its parts - there is no fallback to inventory; parts with no matching stock configured come back as items not placed. Returns optimized cut layouts per material with efficiency stats.")]
public async Task<OptimizeJobResult> OptimizeJob(
[Description("Job ID")]
int jobId,
+3
View File
@@ -6,6 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/report.css" />
@@ -6,7 +6,7 @@
</div>
<main>
<article class="content px-4">
<article class="content">
@Body
</article>
</main>
+11 -18
View File
@@ -1,6 +1,7 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">CutList</a>
<div class="top-row">
<div class="brand-lockup">
<a class="brand-mark" href="" aria-label="CutList home"><i class="bi bi-scissors"></i></a>
<a class="navbar-brand" href=""><span class="brand-cut">Cut</span><span class="brand-list">List</span><small>SHOP FLOOR EDITION</small></a>
</div>
</div>
@@ -8,39 +9,31 @@
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<p class="nav-section-label">Workspace</p>
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
<i class="bi bi-grid-1x2-fill" aria-hidden="true"></i> Overview
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="jobs">
<span class="bi bi-list-check-nav-menu" aria-hidden="true"></span> Jobs
<i class="bi bi-layers-fill" aria-hidden="true"></i> Jobs
</NavLink>
</div>
<p class="nav-section-label">Library</p>
<div class="nav-item px-3">
<NavLink class="nav-link" href="materials">
<span class="bi bi-box-nav-menu" aria-hidden="true"></span> Materials
<i class="bi bi-box-seam-fill" aria-hidden="true"></i> Materials
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="stock">
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="orders">
<span class="bi bi-cart-nav-menu" aria-hidden="true"></span> Orders
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="suppliers">
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers
<i class="bi bi-rulers" aria-hidden="true"></i> Stock Items
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="tools">
<span class="bi bi-tools-nav-menu" aria-hidden="true"></span> Cutting Tools
<i class="bi bi-tools" aria-hidden="true"></i> Cutting Tools
</NavLink>
</div>
</nav>
+20 -119
View File
@@ -1,119 +1,20 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-list-check-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-check' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3.854 2.146a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 3.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 1 1 .708-.708L2 7.293l1.146-1.147a.5.5 0 0 1 .708 0zm0 4a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0l-.5-.5a.5.5 0 0 1 .708-.708l.146.147 1.146-1.147a.5.5 0 0 1 .708 0z'/%3E%3C/svg%3E");
}
.bi-box-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box' viewBox='0 0 16 16'%3E%3Cpath d='M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z'/%3E%3C/svg%3E");
}
.bi-boxes-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-boxes' viewBox='0 0 16 16'%3E%3Cpath d='M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434zM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567zM7.5 9.933l-2.75 1.571v3.134l2.75-1.571zm1 3.134 2.75 1.571v-3.134L8.5 9.933zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567zm2.242-2.433V3.504L8.5 5.076V8.21zM7.5 8.21V5.076L4.75 3.504v3.134zM5.258 2.643 8 4.21l2.742-1.567L8 1.076zM15 9.933l-2.75 1.571v3.134L15 13.067zM3.75 14.638v-3.134L1 9.933v3.134z'/%3E%3C/svg%3E");
}
.bi-cart-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-cart3' viewBox='0 0 16 16'%3E%3Cpath d='M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
}
.bi-building-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-building' viewBox='0 0 16 16'%3E%3Cpath d='M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z'/%3E%3Cpath d='M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z'/%3E%3C/svg%3E");
}
.bi-tools-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tools' viewBox='0 0 16 16'%3E%3Cpath d='M1 0 0 1l2.2 3.081a1 1 0 0 0 .815.419h.07a1 1 0 0 1 .708.293l2.675 2.675-2.617 2.654A3.003 3.003 0 0 0 0 13a3 3 0 1 0 5.878-.851l2.654-2.617.968.968-.305.914a1 1 0 0 0 .242 1.023l3.27 3.27a.997.997 0 0 0 1.414 0l1.586-1.586a.997.997 0 0 0 0-1.414l-3.27-3.27a1 1 0 0 0-1.023-.242l-.914.305-.968-.968 2.617-2.654A3.003 3.003 0 0 0 13 0a3 3 0 1 0-.851 5.878L9.495 8.53l-2.675-2.675a1 1 0 0 1-.293-.707v-.071a1 1 0 0 0-.419-.814L3.081 2.2 1 0ZM3 13a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm7.5-8.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
display: block;
position: relative;
overflow-y: auto;
flex-grow: 1;
}
}
.top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
.brand-lockup { display: flex; align-items: center; gap: .75rem; }
.brand-mark { align-items: center; background: #2b75a8; border-radius: .45rem; color: #fff; display: flex; font-size: 1rem; height: 2.25rem; justify-content: center; text-decoration: none; transform: rotate(-8deg); width: 2.25rem; }
.brand-mark i { transform: rotate(8deg); }
.navbar-brand { color: #17212b; font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.25rem; font-weight: 700; letter-spacing: -.035em; line-height: .95; text-decoration: none; }
.navbar-brand .brand-cut { color: #2b75a8; }
.navbar-brand .brand-list { color: #17212b; }
.navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
.nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
.nav-item { padding: .1rem .75rem; }
.nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #c4d3dc; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
.nav-item ::deep .nav-link i { color: #8fa7b7; font-size: 1rem; width: 1.1rem; }
.nav-item ::deep .nav-link:hover { background: rgba(255,255,255,.06); color: #fff; }
.nav-item ::deep a.active { background: #294655; color: #fff; }
.nav-item ::deep a.active i { color: #72b5df; }
.navbar-toggler { appearance: none; background: transparent; border: 1px solid #587382; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
.navbar-toggler::after { content: '☰'; }
.nav-scrollable { display: none; }
.navbar-toggler:checked ~ .nav-scrollable { display: block; }
@media (min-width: 641px) { .navbar-toggler { display: none; } .nav-scrollable { display: block; } }
+137 -52
View File
@@ -1,58 +1,143 @@
@page "/"
@inject OverviewService OverviewService
@using CutList.Core.Formatting
<PageTitle>CutList - Home</PageTitle>
<PageTitle>CutList - Overview</PageTitle>
<h1>CutList</h1>
<p class="lead">1D Bin Packing Optimization for Material Cutting</p>
<div class="row mt-4">
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Jobs</h5>
<p class="card-text">Create and manage cut list jobs. Add parts and stock bins, then optimize to minimize waste.</p>
<a href="jobs" class="btn btn-primary">Go to Jobs</a>
</div>
@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>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Materials</h5>
<p class="card-text">Manage material types (tube, bar, angle, etc.) with their shapes and sizes.</p>
<a href="materials" class="btn btn-outline-primary">Manage Materials</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Suppliers</h5>
<p class="card-text">Track suppliers and their available stock lengths for quick project setup.</p>
<a href="suppliers" class="btn btn-outline-primary">Manage Suppliers</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Cutting Tools</h5>
<p class="card-text">Configure cutting tools with their kerf widths for accurate waste calculations.</p>
<a href="tools" class="btn btn-outline-primary">Manage Tools</a>
</div>
</div>
</div>
</div>
</section>
<hr class="my-4" />
@if (loadError is not null)
{
<div class="alert alert-warning mt-4" role="alert">
<strong>Overview data is unavailable.</strong> @loadError
</div>
}
<h4>How It Works</h4>
<ol>
<li><strong>Set up materials</strong> - Define the shapes and sizes of materials you work with</li>
<li><strong>Add suppliers</strong> - Track which stock lengths are available from your suppliers</li>
<li><strong>Create a job</strong> - Add the parts you need to cut with their lengths and quantities</li>
<li><strong>Add stock bins</strong> - Specify which stock lengths to cut from (import from supplier or add manually)</li>
<li><strong>Optimize</strong> - Run the optimizer to find the best cutting pattern</li>
<li><strong>Print report</strong> - Generate a printable cut list to take to the shop</li>
</ol>
<section class="overview-stats" aria-label="CutList statistics">
<a href="jobs" class="overview-stat">
<span class="overview-stat-label">Jobs</span>
<strong>@overview.TotalJobs</strong>
<small>@overview.OpenJobs open for planning</small>
</a>
<a href="jobs" class="overview-stat">
<span class="overview-stat-label">Parts required</span>
<strong>@overview.TotalParts</strong>
<small>Across all current jobs</small>
</a>
<a href="stock" class="overview-stat">
<span class="overview-stat-label">Stock options</span>
<strong>@overview.ActiveStockItems</strong>
<small>Active catalog lengths</small>
</a>
</section>
<section class="overview-grid">
<article class="overview-panel overview-recent-jobs">
<div class="overview-panel-heading">
<div>
<p class="eyebrow">Work queue</p>
<h2>Recently created jobs</h2>
</div>
<a href="jobs">View all <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
</div>
@if (overview.RecentJobs.Count == 0)
{
<div class="overview-empty">
<i class="bi bi-layers" aria-hidden="true"></i>
<p>No jobs yet. Create a job to begin building your planning history.</p>
<a href="jobs/new" class="btn btn-primary">Create first job</a>
</div>
}
else
{
<div class="overview-job-list">
@foreach (var job in overview.RecentJobs)
{
<a href="jobs/@job.Id" class="overview-job-row">
<span class="overview-job-id">@job.JobNumber</span>
<span class="overview-job-name">@(string.IsNullOrWhiteSpace(job.Name) ? "Untitled job" : job.Name)</span>
<span class="overview-job-meta">@(string.IsNullOrWhiteSpace(job.Customer) ? "No customer" : job.Customer) · @job.PartCount @(job.PartCount == 1 ? "part" : "parts")</span>
<time datetime="@job.CreatedAt.ToString("O")">@job.CreatedAt.ToLocalTime().ToString("MMM d")</time>
@if (job.IsLocked)
{
<i class="bi bi-lock-fill overview-lock" title="Materials ordered" aria-label="Materials ordered"></i>
}
else
{
<i class="bi bi-arrow-right overview-row-arrow" aria-hidden="true"></i>
}
</a>
}
</div>
}
</article>
<article class="overview-panel overview-stock-panel">
<div class="overview-panel-heading">
<div>
<p class="eyebrow">Planning signal</p>
<h2>Frequently specified stock</h2>
</div>
<a href="stock">Manage stock <i class="bi bi-arrow-up-right" aria-hidden="true"></i></a>
</div>
<p class="overview-panel-note">Based on stock configured on jobs. This shows recurring planning demand, not on-hand inventory.</p>
@if (overview.FrequentStock.Count == 0)
{
<div class="overview-empty overview-empty-compact">
<i class="bi bi-box-seam" aria-hidden="true"></i>
<p>Add stock to jobs to reveal the configurations used most often.</p>
</div>
}
else
{
<ol class="overview-stock-list">
@for (var stockIndex = 0; stockIndex < overview.FrequentStock.Count; stockIndex++)
{
var stock = overview.FrequentStock[stockIndex];
<li>
<span class="overview-stock-rank">@((stockIndex + 1).ToString("D2"))</span>
<div>
<strong>@stock.MaterialName</strong>
<span>@ArchUnits.FormatFromInches((double)stock.LengthInches) stock length</span>
</div>
<span class="overview-stock-count">@stock.JobCount <small>@(stock.JobCount == 1 ? "job" : "jobs")</small></span>
</li>
}
</ol>
}
</article>
</section>
}
@code {
private OverviewSnapshot overview = new(0, 0, 0, 0, [], []);
private bool loading = true;
private string? loadError;
protected override async Task OnInitializedAsync()
{
try
{
overview = await OverviewService.GetSnapshotAsync();
}
catch (Exception ex)
{
loadError = $"The dashboard could not connect to its database ({ex.GetType().Name}).";
}
finally
{
loading = false;
}
}
}
+307 -333
View File
@@ -2,9 +2,7 @@
@page "/jobs/{Id:int}"
@inject JobService JobService
@inject MaterialService MaterialService
@inject StockItemService StockItemService
@inject CutListPackingService PackingService
@inject PurchaseItemService PurchaseItemService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using CutList.Core
@@ -15,16 +13,16 @@
<PageTitle>@(IsNew ? "New Job" : job.DisplayName)</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
<h1 class="job-title">@(IsNew ? "New Job" : job.DisplayName)</h1>
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
</div>
@if (!IsNew && job.IsLocked)
{
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only">
<div>
<i class="bi bi-lock-fill me-2"></i>
<strong>This job is locked</strong> — materials ordered 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.
</div>
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
<i class="bi bi-unlock"></i> Unlock Job
@@ -231,94 +229,226 @@ else
</div>
}
@* Import Stock Modal *@
@if (showImportModal)
@* Add/Edit Stock Modal *@
@if (showStockModal)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import Stock from Inventory</h5>
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
<h5 class="modal-title">@(editingStock == null ? "Add Stock" : "Edit Stock")</h5>
<button type="button" class="btn-close" @onclick="CloseStockModal"></button>
</div>
<div class="modal-body">
@if (loadingImport)
@if (editingStock == null)
{
<div class="text-center py-4">
<span class="spinner-border"></span>
<p class="mt-2 text-muted">Finding matching stock...</p>
</div>
<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>
}
else if (importCandidates.Count == 0)
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
<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>
@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="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>On Hand</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))
<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)
{
<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>@candidate.StockItem.QuantityOnHand</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>
<option value="@shape">@shape.GetDisplayName()</option>
}
</tbody>
</table>
</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>
}
}
@if (!string.IsNullOrEmpty(importErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
@if (importCandidates.Count > 0)
<button type="button" class="btn btn-outline-secondary" @onclick="CloseStockModal">Cancel</button>
@if (stockModalTab == StockModalTab.Inventory && editingStock == null)
{
<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" : "")
@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>
@@ -354,18 +484,16 @@ else
private int partSelectedMaterialId;
private List<PartRow> partRows = new();
// Stock form
private bool showStockForm;
private bool showCustomStockForm;
// Stock modal (unified add/edit, both inventory-sourced and custom-length)
private enum StockModalTab { Inventory, Custom }
private bool showStockModal;
private StockModalTab stockModalTab = StockModalTab.Inventory;
private JobStock newStock = new();
private JobStock? editingStock;
private string? stockErrorMessage;
private MaterialShape? stockSelectedShape;
private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new();
// Import modal
private bool showImportModal;
private bool loadingImport;
private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage;
@@ -374,8 +502,8 @@ else
private MultiMaterialPackResult? packResult;
private MultiMaterialPackingSummary? summary;
private bool optimizing;
private bool addingToOrderList;
private bool addedToOrderList;
private bool lockingJob;
private bool showLengthsInInches = true;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
@@ -384,6 +512,9 @@ else
private bool IsNew => !Id.HasValue;
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
private string FormatResultLength(double inches) => showLengthsInInches
? ArchUnits.FormatInches(inches)
: ArchUnits.FormatFromInches(inches);
private async Task UnlockJob()
{
@@ -443,7 +574,6 @@ else
if (packResult != null)
{
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
}
catch
@@ -560,8 +690,10 @@ else
<td>
@if (!job.IsLocked)
{
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
}
</td>
</tr>
@@ -747,31 +879,16 @@ else
<h5 class="mb-0">Stock for This Job</h5>
@if (!job.IsLocked)
{
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
<button class="btn btn-primary" @onclick="ShowAddStockModal">Add Stock</button>
}
</div>
<div class="card-body">
@if (showStockForm)
{
@RenderStockFromInventoryForm()
}
else if (showCustomStockForm)
{
@RenderCustomStockForm()
}
@if (job.Stock.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No stock configured for this job.</p>
<p class="small">Add stock from your inventory or define custom lengths.</p>
<p class="small">If no stock is selected, the optimizer will use all available stock for the materials in your parts list.</p>
<p class="small">Stock must be explicitly added here before you can optimize — there is no automatic fallback to inventory.</p>
</div>
}
else
@@ -782,122 +899,6 @@ else
</div>
};
private RenderFragment RenderStockFromInventoryForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Stock from Inventory" : "Edit Stock Selection")</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="stockSelectedMaterialId" @bind:after="OnStockMaterialChanged"
disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Stock Length</label>
<select class="form-select" @bind="newStock.StockItemId" disabled="@(stockSelectedMaterialId == 0)">
<option value="">-- Select --</option>
@foreach (var stock in availableStockItems)
{
<option value="@stock.Id">@ArchUnits.FormatFromInches((double)stock.LengthInches) (@stock.QuantityOnHand available)</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Qty to Use</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveStockFromInventoryAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
};
private RenderFragment RenderCustomStockForm() => __builder =>
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingStock == null ? "Add Custom Stock Length" : "Edit Custom Stock")</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Shape</label>
<select class="form-select" @bind="stockSelectedShape" @bind:after="OnStockShapeChanged">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Size</label>
<select class="form-select" @bind="newStock.MaterialId" disabled="@(!stockSelectedShape.HasValue)">
<option value="0">-- Select --</option>
@foreach (var material in materials.Where(m => stockSelectedShape.HasValue && m.Shape == stockSelectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size))
{
<option value="@material.Id">@material.Size</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newStock.LengthInches" />
</div>
<div class="col-md-3">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newStock.Quantity" min="1" />
<small class="text-muted">Use -1 for unlimited</small>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Priority</label>
<input type="number" class="form-control" @bind="newStock.Priority" min="1" />
<small class="text-muted">Lower = used first</small>
</div>
</div>
@if (!string.IsNullOrEmpty(stockErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@stockErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" @onclick="SaveCustomStockAsync">
@(editingStock == null ? "Add Stock" : "Save Changes")
</button>
<button class="btn btn-outline-secondary" @onclick="CancelStockForm">Cancel</button>
</div>
</div>
};
private RenderFragment RenderStockTable() => __builder =>
{
<div class="table-responsive">
@@ -933,8 +934,10 @@ else
<td>
@if (!job.IsLocked)
{
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
}
</td>
</tr>
@@ -988,6 +991,20 @@ else
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
<i class="bi bi-printer me-1"></i> Print Report
</button>
<button class="btn btn-outline-secondary ms-2" @onclick="() => showLengthsInInches = !showLengthsInInches"
aria-pressed="@showLengthsInInches">
@(showLengthsInInches ? "Show feet + inches" : "Show all inches")
</button>
@if (!job.IsLocked)
{
<button class="btn btn-warning ms-2" @onclick="LockJob" disabled="@lockingJob">
@if (lockingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-lock me-1"></i>Lock Job
</button>
}
}
@if (job.OptimizedAt.HasValue)
{
@@ -1028,7 +1045,7 @@ else
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
<h2 class="card-title mb-0">@FormatResultLength(summary.TotalWaste)</h2>
<p class="card-text text-muted">Total Waste</p>
</div>
</div>
@@ -1043,41 +1060,30 @@ else
</div>
</div>
<!-- Purchase List -->
<div class="card mb-4 print-purchase-list">
@if (job.CuttingTool != null)
{
<div class="print-cut-method">
<strong>Cut Method:</strong> @job.CuttingTool.Name
<span class="ms-2">Kerf: @FormatResultLength((double)job.CuttingTool.KerfInches)</span>
</div>
}
<!-- Material List: required stock lengths not covered by job stock -->
<div class="card mb-4 print-material-list">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
@if (summary.TotalToBePurchasedBins > 0)
<h5 class="mb-0"><i class="bi bi-box-seam me-2"></i>Material List</h5>
@if (job.IsLocked)
{
@if (addedToOrderList)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus me-1"></i>Add to Order List
</button>
}
<span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
<p class="text-muted mb-0">No additional material lengths are required beyond the stock configured for this job.</p>
}
else
{
@if (addedToOrderList)
{
<div class="alert alert-success py-2 mb-3">
Items added to order list. <a href="orders">View Orders</a>
</div>
}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
@@ -1094,7 +1100,7 @@ else
{
<tr>
<td>@materialResult.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches(group.Key)</td>
<td>@FormatResultLength(group.Key)</td>
<td class="text-end">@group.Count()</td>
</tr>
}
@@ -1102,7 +1108,7 @@ else
</tbody>
<tfoot>
<tr class="fw-bold">
<td colspan="2">Total</td>
<td colspan="2">Total Required</td>
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
</tr>
</tfoot>
@@ -1138,7 +1144,7 @@ else
{
<div class="alert alert-danger">
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> —
No stock lengths available or parts too long.
not enough stock quantity entered for this job, no stock lengths configured, or parts too long.
</div>
}
@@ -1168,16 +1174,21 @@ else
{
<tr>
<td>@binNum</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.Length)</td>
<td>
@foreach (var item in entry.Bin.Items)
{
<span class="badge bg-primary me-1 fs-6">
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
@if (!string.IsNullOrWhiteSpace(item.Name))
{
<span class="cut-part-badge-name">@item.Name</span>
<span class="cut-part-badge-divider" aria-hidden="true"></span>
}
<span class="cut-part-badge-length">@FormatResultLength(item.Length)</span>
</span>
}
</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
</tr>
binNum++;
}
@@ -1214,7 +1225,6 @@ else
// Refresh job to get updated OptimizedAt
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = job.IsLocked;
}
finally
{
@@ -1222,55 +1232,17 @@ else
}
}
private async Task AddToOrderList()
private async Task LockJob()
{
addingToOrderList = true;
lockingJob = true;
try
{
var purchaseItems = new List<PurchaseItem>();
var stockItems = await StockItemService.GetAllAsync();
foreach (var materialResult in packResult!.MaterialResults)
{
if (materialResult.ToBePurchasedBins.Count == 0) continue;
var materialId = materialResult.Material.Id;
// Group bins by length to consolidate quantities
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
{
var lengthInches = (decimal)group.Key;
var quantity = group.Count();
// Find the matching stock item
var stockItem = stockItems.FirstOrDefault(s =>
s.MaterialId == materialId && s.LengthInches == lengthInches);
if (stockItem != null)
{
purchaseItems.Add(new PurchaseItem
{
StockItemId = stockItem.Id,
Quantity = quantity,
JobId = Id!.Value,
Status = PurchaseItemStatus.Pending
});
}
}
}
if (purchaseItems.Count > 0)
{
await PurchaseItemService.CreateBulkAsync(purchaseItems);
}
await JobService.LockAsync(Id!.Value);
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = true;
}
finally
{
addingToOrderList = false;
lockingJob = false;
}
}
@@ -1280,21 +1252,33 @@ else
await JS.InvokeVoidAsync("printWithTitle", filename);
}
private void ShowAddCustomStock()
private async Task ShowAddStockModal()
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10, IsCustomLength = true };
newStock = new JobStock { JobId = Id!.Value, Quantity = -1, Priority = 10 };
stockSelectedShape = null;
showStockForm = false;
showCustomStockForm = true;
stockSelectedMaterialId = 0;
availableStockItems.Clear();
stockErrorMessage = null;
importErrorMessage = null;
importCandidates.Clear();
stockModalTab = job.Parts.Count > 0 ? StockModalTab.Inventory : StockModalTab.Custom;
showStockModal = true;
if (job.Parts.Count > 0)
{
await LoadImportCandidatesAsync();
}
}
private void CancelStockForm()
private void CloseStockModal()
{
showStockForm = false;
showCustomStockForm = false;
showStockModal = false;
editingStock = null;
newStock = new();
importCandidates.Clear();
stockErrorMessage = null;
importErrorMessage = null;
}
private async Task OnStockShapeChanged()
@@ -1309,17 +1293,10 @@ else
{
newStock.MaterialId = stockSelectedMaterialId;
newStock.StockItemId = null;
if (stockSelectedMaterialId > 0)
{
availableStockItems = await JobService.GetAvailableStockForMaterialAsync(stockSelectedMaterialId);
}
else
{
availableStockItems.Clear();
}
await LoadAvailableStockItemsAsync(stockSelectedMaterialId);
}
private void EditStock(JobStock stock)
private async Task EditStock(JobStock stock)
{
editingStock = stock;
newStock = new JobStock
@@ -1337,20 +1314,25 @@ else
stockSelectedShape = stock.Material?.Shape;
stockSelectedMaterialId = stock.MaterialId;
stockErrorMessage = null;
stockModalTab = stock.IsCustomLength ? StockModalTab.Custom : StockModalTab.Inventory;
showStockModal = true;
if (stock.IsCustomLength)
if (!stock.IsCustomLength)
{
showStockForm = false;
showCustomStockForm = true;
}
else
{
showStockForm = true;
showCustomStockForm = false;
_ = OnStockMaterialChanged();
// Load the candidate stock lengths without going through OnStockMaterialChanged,
// which would reset newStock.StockItemId and blank the prefilled selection.
// Awaited (not fire-and-forget) so Blazor re-renders once the list arrives.
await LoadAvailableStockItemsAsync(stock.MaterialId);
}
}
private async Task LoadAvailableStockItemsAsync(int materialId)
{
availableStockItems = materialId > 0
? await JobService.GetAvailableStockForMaterialAsync(materialId)
: new List<StockItem>();
}
private async Task SaveStockFromInventoryAsync()
{
stockErrorMessage = null;
@@ -1373,9 +1355,9 @@ else
return;
}
if (newStock.Quantity < 1)
if (newStock.Quantity < -1 || newStock.Quantity == 0)
{
stockErrorMessage = "Quantity must be at least 1";
stockErrorMessage = "Quantity must be at least 1 (or -1 for unlimited)";
return;
}
@@ -1400,7 +1382,7 @@ else
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false;
showStockModal = false;
editingStock = null;
packResult = null;
summary = null;
@@ -1447,7 +1429,7 @@ else
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false;
showStockModal = false;
editingStock = null;
packResult = null;
summary = null;
@@ -1461,13 +1443,12 @@ else
summary = null;
}
// Import modal methods
private async Task ShowImportModal()
// Loads inventory stock candidates for the modal's "From Inventory" tab
private async Task LoadImportCandidatesAsync()
{
importErrorMessage = null;
importCandidates.Clear();
loadingImport = true;
showImportModal = true;
try
{
@@ -1502,13 +1483,6 @@ else
}
}
private void CloseImportModal()
{
showImportModal = false;
importCandidates.Clear();
importErrorMessage = null;
}
private void ToggleAllImportCandidates(bool selected)
{
foreach (var c in importCandidates)
@@ -1543,7 +1517,7 @@ else
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false;
showStockModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
@@ -62,9 +62,11 @@ else
<td>@(job.CuttingTool?.Name ?? "-")</td>
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
<td>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
<div class="table-actions">
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
@@ -62,7 +62,7 @@ else
<td>@material.Grade</td>
<td>@material.Size</td>
<td>
<div class="d-flex gap-1">
<div class="table-actions">
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
@@ -1,151 +0,0 @@
@page "/orders/add"
@inject PurchaseItemService PurchaseItemService
@inject StockItemService StockItemService
@inject SupplierService SupplierService
@inject JobService JobService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Add Order Item</PageTitle>
<h1>Add Order Item</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Order Item Details</h5>
</div>
<div class="card-body">
<EditForm Model="item" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Stock Item</label>
<select class="form-select" @bind="item.StockItemId">
<option value="0">-- Select Stock Item --</option>
@foreach (var group in stockItemGroups)
{
<optgroup label="@group.Key">
@foreach (var si in group.Value)
{
<option value="@si.Id">@si.Material.Size - @ArchUnits.FormatFromInches((double)si.LengthInches)</option>
}
</optgroup>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="item.Quantity" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="item.SupplierId">
<option value="">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Job (optional)</label>
<select class="form-select" @bind="item.JobId">
<option value="">-- Select Job --</option>
@foreach (var job in jobs)
{
<option value="@job.Id">@job.DisplayName</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="item.Notes" placeholder="Any notes about this order" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@saving">
@if (saving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Add to Order List
</button>
<a href="orders" class="btn btn-outline-secondary">Cancel</a>
</div>
</EditForm>
</div>
</div>
</div>
</div>
}
@code {
private PurchaseItem item = new() { Quantity = 1 };
private List<StockItem> stockItems = new();
private Dictionary<string, List<StockItem>> stockItemGroups = new();
private List<Supplier> suppliers = new();
private List<Job> jobs = new();
private bool loading = true;
private bool saving;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
stockItems = await StockItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
jobs = await JobService.GetAllAsync();
stockItemGroups = stockItems
.GroupBy(s => s.Material.Shape.GetDisplayName())
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.OrderBy(s => s.Material.Size).ThenBy(s => s.LengthInches).ToList());
loading = false;
}
private async Task SaveAsync()
{
errorMessage = null;
saving = true;
try
{
if (item.StockItemId == 0)
{
errorMessage = "Please select a stock item";
return;
}
if (item.Quantity <= 0)
{
errorMessage = "Quantity must be at least 1";
return;
}
await PurchaseItemService.CreateAsync(item);
Navigation.NavigateTo("orders");
}
finally
{
saving = false;
}
}
}
@@ -1,291 +0,0 @@
@page "/orders"
@inject PurchaseItemService PurchaseItemService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Orders</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>To Be Ordered</h1>
<a href="orders/add" class="btn btn-primary">Add Item</a>
</div>
<p class="text-muted mb-4">
Track material that needs to be ordered from suppliers. Items are added manually or automatically from job optimization results.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeTab == "pending" ? "active" : "")" @onclick='() => SetTab("pending")'>
Pending
@if (pendingCount > 0)
{
<span class="badge bg-warning text-dark ms-1">@pendingCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "ordered" ? "active" : "")" @onclick='() => SetTab("ordered")'>
Ordered
@if (orderedCount > 0)
{
<span class="badge bg-primary ms-1">@orderedCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "all" ? "active" : "")" @onclick='() => SetTab("all")'>All</button>
</li>
</ul>
@if (tabItems.Count == 0)
{
<div class="alert alert-info">
@if (activeTab == "pending")
{
<span>No pending items. <a href="orders/add">Add an item</a> or use "Add to Order List" from a job's results page.</span>
}
else if (activeTab == "ordered")
{
<span>No ordered items.</span>
}
else
{
<span>No order items found. <a href="orders/add">Add your first item</a>.</span>
}
</div>
}
else
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredItems.Count == 0)
{
<div class="alert alert-warning">
No items match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Supplier</th>
<th>Job</th>
<th>Status</th>
<th>Notes</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in pagedItems)
{
<tr>
<td>@item.StockItem.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)</td>
<td>@item.Quantity</td>
<td>
<select class="form-select form-select-sm" style="min-width: 140px;"
value="@(item.SupplierId?.ToString() ?? "")"
@onchange="(e) => OnSupplierChanged(item, e)">
<option value="">-- Select --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</td>
<td>
@if (item.Job != null)
{
<a href="jobs/@item.Job.Id">@item.Job.DisplayName</a>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
<span class="badge @GetStatusBadgeClass(item.Status)">@item.Status</span>
</td>
<td>
<span class="text-muted small">@(item.Notes ?? "-")</span>
</td>
<td>
<div class="d-flex gap-1">
@if (item.Status == PurchaseItemStatus.Pending)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => MarkOrdered(item)" title="Mark Ordered">
<i class="bi bi-truck"></i>
</button>
}
@if (item.Status == PurchaseItemStatus.Ordered)
{
<button class="btn btn-sm btn-outline-success" @onclick="() => MarkReceived(item)" title="Mark Received">
<i class="bi bi-check-lg"></i>
</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredItems.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Order Item"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<PurchaseItem> allItems = new();
private List<Supplier> suppliers = new();
private bool loading = true;
private string activeTab = "pending";
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private PurchaseItem? itemToDelete;
private string deleteMessage = "";
private MaterialFilterState filterState = new();
private int pendingCount => allItems.Count(i => i.Status == PurchaseItemStatus.Pending);
private int orderedCount => allItems.Count(i => i.Status == PurchaseItemStatus.Ordered);
private List<PurchaseItem> tabItems => activeTab switch
{
"pending" => allItems.Where(i => i.Status == PurchaseItemStatus.Pending).ToList(),
"ordered" => allItems.Where(i => i.Status == PurchaseItemStatus.Ordered).ToList(),
_ => allItems
};
private List<PurchaseItem> filteredItems => tabItems.Where(i =>
{
var m = i.StockItem.Material;
if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value)
return false;
if (filterState.Type.HasValue && m.Type != filterState.Type.Value)
return false;
if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade)
return false;
if (!string.IsNullOrWhiteSpace(filterState.SearchText))
{
var search = filterState.SearchText.Trim();
if (!Contains(m.Size, search)
&& !Contains(m.Grade, search)
&& !Contains(m.Shape.GetDisplayName(), search)
&& !Contains(i.Notes, search)
&& !Contains(i.Job?.DisplayName, search)
&& !Contains(i.Supplier?.Name, search))
return false;
}
return true;
}).ToList();
private IEnumerable<string> availableGrades => tabItems
.Select(i => i.StockItem.Material.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<PurchaseItem> pagedItems => filteredItems
.Skip((currentPage - 1) * pageSize)
.Take(pageSize);
protected override async Task OnInitializedAsync()
{
allItems = await PurchaseItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void SetTab(string tab)
{
activeTab = tab;
currentPage = 1;
filterState = new();
}
private void OnFilterChanged(MaterialFilterState state)
{
filterState = state;
currentPage = 1;
}
private async Task OnSupplierChanged(PurchaseItem item, ChangeEventArgs e)
{
int? supplierId = int.TryParse(e.Value?.ToString(), out var id) && id > 0 ? id : null;
await PurchaseItemService.UpdateSupplierAsync(item.Id, supplierId);
item.SupplierId = supplierId;
item.Supplier = supplierId.HasValue ? suppliers.FirstOrDefault(s => s.Id == supplierId.Value) : null;
}
private async Task MarkOrdered(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Ordered);
item.Status = PurchaseItemStatus.Ordered;
}
private async Task MarkReceived(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Received);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
private void ConfirmDelete(PurchaseItem item)
{
itemToDelete = item;
deleteMessage = $"Are you sure you want to delete this order item ({item.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)} x{item.Quantity})?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (itemToDelete != null)
{
await PurchaseItemService.DeleteAsync(itemToDelete.Id);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private static string GetStatusBadgeClass(PurchaseItemStatus status) => status switch
{
PurchaseItemStatus.Pending => "bg-warning text-dark",
PurchaseItemStatus.Ordered => "bg-primary",
PurchaseItemStatus.Received => "bg-success",
_ => "bg-secondary"
};
private static bool Contains(string? value, string search) =>
value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase);
}
+13 -427
View File
@@ -2,7 +2,6 @@
@page "/stock/{Id:int}"
@inject StockItemService StockItemService
@inject MaterialService MaterialService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@@ -39,14 +38,7 @@ else
<div class="mb-3">
<label class="form-label">Length</label>
@if (IsNew)
{
<LengthInput @bind-Value="stockItem.LengthInches" />
}
else
{
<input type="text" class="form-control" value="@ArchUnits.FormatFromInches((double)stockItem.LengthInches)" readonly />
}
<LengthInput @bind-Value="stockItem.LengthInches" />
</div>
<div class="mb-3">
@@ -78,252 +70,24 @@ else
</div>
</div>
</div>
@if (!IsNew)
{
<div class="col-lg-6 mb-4">
<div class="card mb-4">
<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>
@if (stockTransactionType == "add")
{
<div class="col-md-6">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="stockSupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Unit Price (optional)</label>
<input type="number" class="form-control" @bind="stockUnitPrice" step="0.01" min="0" placeholder="0.00" />
</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>Supplier</th>
<th>Price</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.Supplier?.Name ?? "-")</td>
<td>@(txn.UnitPrice.HasValue ? txn.UnitPrice.Value.ToString("C") : "-")</td>
<td>@(txn.Notes ?? "-")</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div>
<div class="card-body">
@if (showOfferingForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Supplier</label>
<select class="form-select" @bind="newOffering.SupplierId">
<option value="0">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Part Number</label>
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div>
<div class="col-md-6">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div>
<div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newOffering.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(offeringErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingOffering)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingOffering == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div>
</div>
}
@if (offerings.Count == 0)
{
<p class="text-muted">No supplier offerings configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Supplier</th>
<th>Part #</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var offering in offerings)
{
<tr>
<td>@offering.Supplier.Name</td>
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Offering"
Message="@deleteOfferingMessage"
ConfirmText="Delete"
OnConfirm="DeleteOfferingConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private StockItem stockItem = new();
private List<Material> materials = new();
private List<Supplier> suppliers = new();
private List<SupplierOffering> offerings = new();
private List<StockTransaction> transactions = new();
private bool loading = true;
private bool saving;
private bool savingOffering;
private string? errorMessage;
private string? offeringErrorMessage;
private bool showOfferingForm;
private SupplierOffering newOffering = new();
private SupplierOffering? editingOffering;
// Stock transaction form
private bool showStockForm;
private bool savingStockTransaction;
private string stockTransactionType = "add";
private int stockQuantity;
private int stockSupplierId;
private decimal? stockUnitPrice;
private string? stockNotes;
private string? stockFormErrorMessage;
private ConfirmDialog deleteOfferingDialog = null!;
private SupplierOffering? offeringToDelete;
private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
materials = await MaterialService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
if (Id.HasValue)
{
@@ -334,91 +98,10 @@ else
return;
}
stockItem = existing;
offerings = existing.SupplierOfferings.Where(o => o.IsActive).ToList();
transactions = await StockItemService.GetTransactionHistoryAsync(Id.Value, 20);
}
loading = false;
}
private string GetTransactionBadgeClass(StockTransactionType type) => type switch
{
StockTransactionType.Received => "bg-success",
StockTransactionType.Used => "bg-primary",
StockTransactionType.Adjustment => "bg-warning text-dark",
StockTransactionType.Scrapped => "bg-danger",
StockTransactionType.Returned => "bg-info",
_ => "bg-secondary"
};
private void ShowStockForm()
{
stockTransactionType = "add";
stockQuantity = 0;
stockSupplierId = 0;
stockUnitPrice = null;
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,
stockSupplierId > 0 ? stockSupplierId : null,
stockUnitPrice,
stockNotes);
break;
case "adjust":
await StockItemService.AdjustStockAsync(Id!.Value, stockQuantity, stockNotes);
break;
case "scrap":
await StockItemService.ScrapStockAsync(Id!.Value, stockQuantity, stockNotes);
break;
}
// Refresh
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
}
transactions = await StockItemService.GetTransactionHistoryAsync(Id!.Value, 20);
showStockForm = false;
}
finally
{
savingStockTransaction = false;
}
}
private async Task SaveStockItemAsync()
{
errorMessage = null;
@@ -449,14 +132,21 @@ else
return;
}
if (IsNew)
try
{
var created = await StockItemService.CreateAsync(stockItem);
Navigation.NavigateTo($"stock/{created.Id}");
if (IsNew)
{
var created = await StockItemService.CreateAsync(stockItem);
Navigation.NavigateTo($"stock/{created.Id}");
}
else
{
await StockItemService.UpdateAsync(stockItem);
}
}
else
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
{
await StockItemService.UpdateAsync(stockItem);
errorMessage = "A stock item with this material and length already exists (it may have been previously deleted).";
}
}
finally
@@ -464,108 +154,4 @@ else
saving = false;
}
}
private void ShowAddOfferingForm()
{
editingOffering = null;
newOffering = new SupplierOffering { StockItemId = Id!.Value };
showOfferingForm = true;
offeringErrorMessage = null;
}
private void EditOffering(SupplierOffering offering)
{
editingOffering = offering;
newOffering = new SupplierOffering
{
Id = offering.Id,
StockItemId = offering.StockItemId,
SupplierId = offering.SupplierId,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
};
showOfferingForm = true;
offeringErrorMessage = null;
}
private void CancelOfferingForm()
{
showOfferingForm = false;
editingOffering = null;
offeringErrorMessage = null;
}
private async Task SaveOfferingAsync()
{
offeringErrorMessage = null;
savingOffering = true;
try
{
if (newOffering.SupplierId == 0)
{
offeringErrorMessage = "Please select a supplier";
return;
}
var exists = await SupplierService.OfferingExistsAsync(
newOffering.SupplierId,
newOffering.StockItemId,
editingOffering?.Id);
if (exists)
{
offeringErrorMessage = "This supplier already has an offering for this stock item";
return;
}
if (editingOffering == null)
{
await SupplierService.AddOfferingAsync(newOffering);
}
else
{
await SupplierService.UpdateOfferingAsync(newOffering);
}
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
showOfferingForm = false;
editingOffering = null;
}
finally
{
savingOffering = false;
}
}
private void ConfirmDeleteOffering(SupplierOffering offering)
{
offeringToDelete = offering;
deleteOfferingMessage = $"Are you sure you want to delete the offering from {offering.Supplier.Name}?";
deleteOfferingDialog.Show();
}
private async Task DeleteOfferingConfirmed()
{
if (offeringToDelete != null)
{
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
// Refresh the stock item to get updated offerings
var updated = await StockItemService.GetByIdAsync(Id!.Value);
if (updated != null)
{
stockItem = updated;
offerings = updated.SupplierOfferings.Where(o => o.IsActive).ToList();
}
}
}
}
+3 -14
View File
@@ -12,8 +12,8 @@
</div>
<p class="text-muted mb-4">
Stock items represent the specific lengths of material you have available for cutting. Each stock item links
a material to a length and tracks how many pieces you have on hand.
Stock items represent the specific lengths of material you can cut from. Add the lengths you typically
work with here, then pick from them when adding stock to a job.
</p>
@if (loading)
@@ -46,7 +46,6 @@ else
<th>Grade</th>
<th>Size</th>
<th>Length</th>
<th>On Hand</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
@@ -60,17 +59,7 @@ else
<td>@item.Material.Size</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td>
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success">@item.QuantityOnHand</span>
}
else
{
<span class="badge bg-secondary">0</span>
}
</td>
<td>
<div class="d-flex gap-1">
<div class="table-actions">
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
@@ -1,333 +0,0 @@
@page "/suppliers/new"
@page "/suppliers/{Id:int}"
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@inject CutList.Web.Data.ApplicationDbContext DbContext
@using CutList.Core.Formatting
@using Microsoft.EntityFrameworkCore
<PageTitle>@(IsNew ? "Add Supplier" : "Edit Supplier")</PageTitle>
<h1>@(IsNew ? "Add Supplier" : supplier.Name)</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Supplier Details</h5>
</div>
<div class="card-body">
<EditForm Model="supplier" OnValidSubmit="SaveSupplierAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="supplier.Name" />
<ValidationMessage For="@(() => supplier.Name)" />
</div>
<div class="mb-3">
<label class="form-label">Contact Info</label>
<InputTextArea class="form-control" @bind-Value="supplier.ContactInfo" rows="2" placeholder="Phone, email, address..." />
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="supplier.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(supplierErrorMessage))
{
<div class="alert alert-danger">@supplierErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingSupplier">
@if (savingSupplier)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Supplier" : "Save Changes")
</button>
<a href="suppliers" class="btn btn-outline-secondary">@(IsNew ? "Cancel" : "Back to List")</a>
</div>
</EditForm>
</div>
</div>
</div>
@if (!IsNew)
{
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Supplier Offerings</h5>
<button class="btn btn-sm btn-primary" @onclick="ShowAddOfferingForm">Add Offering</button>
</div>
<div class="card-body">
@if (showOfferingForm)
{
<div class="border rounded p-3 mb-3 bg-light">
<h6>@(editingOffering == null ? "Add Offering" : "Edit Offering")</h6>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Stock Item</label>
<select class="form-select" @bind="newOffering.StockItemId">
<option value="0">-- Select Stock Item --</option>
@foreach (var stockItem in stockItems)
{
<option value="@stockItem.Id">@stockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)stockItem.LengthInches)</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Part Number</label>
<InputText class="form-control" @bind-Value="newOffering.PartNumber" />
</div>
<div class="col-md-6">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="newOffering.Price" placeholder="0.00" />
</div>
<div class="col-12">
<label class="form-label">Supplier Description</label>
<InputText class="form-control" @bind-Value="newOffering.SupplierDescription" />
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<InputText class="form-control" @bind-Value="newOffering.Notes" />
</div>
</div>
@if (!string.IsNullOrEmpty(offeringErrorMessage))
{
<div class="alert alert-danger mt-2 mb-0">@offeringErrorMessage</div>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="SaveOfferingAsync" disabled="@savingOffering">
@if (savingOffering)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(editingOffering == null ? "Add" : "Save")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelOfferingForm">Cancel</button>
</div>
</div>
}
@if (offerings.Count == 0)
{
<p class="text-muted">No offerings configured yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Stock Item</th>
<th>Part #</th>
<th>Price</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var offering in offerings)
{
<tr>
<td>@offering.StockItem.Material.DisplayName - @ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)</td>
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
}
</div>
}
<ConfirmDialog @ref="deleteOfferingDialog"
Title="Delete Offering"
Message="@deleteOfferingMessage"
ConfirmText="Delete"
OnConfirm="DeleteOfferingConfirmed" />
@code {
[Parameter]
public int? Id { get; set; }
private Supplier supplier = new();
private List<SupplierOffering> offerings = new();
private List<StockItem> stockItems = new();
private bool loading = true;
private bool savingSupplier;
private bool savingOffering;
private string? supplierErrorMessage;
private string? offeringErrorMessage;
private bool showOfferingForm;
private SupplierOffering newOffering = new();
private SupplierOffering? editingOffering;
private ConfirmDialog deleteOfferingDialog = null!;
private SupplierOffering? offeringToDelete;
private string deleteOfferingMessage = "";
private bool IsNew => !Id.HasValue;
protected override async Task OnInitializedAsync()
{
stockItems = await DbContext.StockItems
.Include(si => si.Material)
.Where(si => si.IsActive)
.OrderBy(si => si.Material.Shape)
.ThenBy(si => si.Material.Size)
.ThenBy(si => si.LengthInches)
.ToListAsync();
if (Id.HasValue)
{
var existing = await SupplierService.GetByIdAsync(Id.Value);
if (existing == null)
{
Navigation.NavigateTo("suppliers");
return;
}
supplier = existing;
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id.Value);
}
loading = false;
}
private async Task SaveSupplierAsync()
{
supplierErrorMessage = null;
savingSupplier = true;
try
{
if (string.IsNullOrWhiteSpace(supplier.Name))
{
supplierErrorMessage = "Name is required";
return;
}
if (IsNew)
{
var created = await SupplierService.CreateAsync(supplier);
Navigation.NavigateTo($"suppliers/{created.Id}");
}
else
{
await SupplierService.UpdateAsync(supplier);
}
}
finally
{
savingSupplier = false;
}
}
private void ShowAddOfferingForm()
{
editingOffering = null;
newOffering = new SupplierOffering { SupplierId = Id!.Value };
showOfferingForm = true;
offeringErrorMessage = null;
}
private void EditOffering(SupplierOffering offering)
{
editingOffering = offering;
newOffering = new SupplierOffering
{
Id = offering.Id,
SupplierId = offering.SupplierId,
StockItemId = offering.StockItemId,
PartNumber = offering.PartNumber,
SupplierDescription = offering.SupplierDescription,
Price = offering.Price,
Notes = offering.Notes
};
showOfferingForm = true;
offeringErrorMessage = null;
}
private void CancelOfferingForm()
{
showOfferingForm = false;
editingOffering = null;
offeringErrorMessage = null;
}
private async Task SaveOfferingAsync()
{
offeringErrorMessage = null;
savingOffering = true;
try
{
if (newOffering.StockItemId == 0)
{
offeringErrorMessage = "Please select a stock item";
return;
}
var exists = await SupplierService.OfferingExistsAsync(
newOffering.SupplierId,
newOffering.StockItemId,
editingOffering?.Id);
if (exists)
{
offeringErrorMessage = "This supplier already has an offering for this stock item";
return;
}
if (editingOffering == null)
{
await SupplierService.AddOfferingAsync(newOffering);
}
else
{
await SupplierService.UpdateOfferingAsync(newOffering);
}
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
showOfferingForm = false;
editingOffering = null;
}
finally
{
savingOffering = false;
}
}
private void ConfirmDeleteOffering(SupplierOffering offering)
{
offeringToDelete = offering;
deleteOfferingMessage = $"Are you sure you want to delete the offering for {offering.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)offering.StockItem.LengthInches)}?";
deleteOfferingDialog.Show();
}
private async Task DeleteOfferingConfirmed()
{
if (offeringToDelete != null)
{
await SupplierService.DeleteOfferingAsync(offeringToDelete.Id);
offerings = await SupplierService.GetOfferingsForSupplierAsync(Id!.Value);
}
}
}
@@ -1,105 +0,0 @@
@page "/suppliers"
@inject SupplierService SupplierService
@inject NavigationManager Navigation
<PageTitle>Suppliers</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Suppliers</h1>
<a href="suppliers/new" class="btn btn-primary">Add Supplier</a>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (suppliers.Count == 0)
{
<div class="alert alert-info">
No suppliers found. <a href="suppliers/new">Add your first supplier</a>.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Contact Info</th>
<th>Notes</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var supplier in pagedSuppliers)
{
<tr>
<td><a href="suppliers/@supplier.Id">@supplier.Name</a></td>
<td>@supplier.ContactInfo</td>
<td>@TruncateText(supplier.Notes, 50)</td>
<td>
<div class="d-flex gap-1">
<a href="suppliers/@supplier.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(supplier)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="suppliers.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Supplier"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<Supplier> suppliers = new();
private bool loading = true;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private Supplier? supplierToDelete;
private string deleteMessage = "";
private IEnumerable<Supplier> pagedSuppliers => suppliers.Skip((currentPage - 1) * pageSize).Take(pageSize);
protected override async Task OnInitializedAsync()
{
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void ConfirmDelete(Supplier supplier)
{
supplierToDelete = supplier;
deleteMessage = $"Are you sure you want to delete \"{supplier.Name}\"? This will also remove all stock lengths associated with this supplier.";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (supplierToDelete != null)
{
await SupplierService.DeleteAsync(supplierToDelete.Id);
suppliers = await SupplierService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)suppliers.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private string? TruncateText(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
return text;
return text.Substring(0, maxLength) + "...";
}
}
@@ -90,8 +90,10 @@ else
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
<div class="table-actions">
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
@@ -310,7 +310,6 @@ public class JobsController : ControllerBase
LengthInches = s.LengthInches,
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
IsActive = s.IsActive
}).ToList());
}
+1 -142
View File
@@ -11,12 +11,10 @@ namespace CutList.Web.Controllers;
public class StockItemsController : ControllerBase
{
private readonly StockItemService _stockItemService;
private readonly SupplierService _supplierService;
public StockItemsController(StockItemService stockItemService, SupplierService supplierService)
public StockItemsController(StockItemService stockItemService)
{
_stockItemService = stockItemService;
_supplierService = supplierService;
}
[HttpGet]
@@ -65,7 +63,6 @@ public class StockItemsController : ControllerBase
MaterialId = dto.MaterialId,
LengthInches = (decimal)lengthInches,
Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes
};
@@ -120,115 +117,6 @@ public class StockItemsController : ControllerBase
return Ok(items.Select(MapToDto).ToList());
}
[HttpGet("{id}/offerings")]
public async Task<ActionResult<List<OfferingDto>>> GetOfferings(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
var offerings = await _supplierService.GetOfferingsForStockItemAsync(id);
return Ok(offerings.Select(MapOfferingToDto).ToList());
}
[HttpGet("{id}/pricing")]
public async Task<ActionResult<StockPricingDto>> GetPricing(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
var avgCost = await _stockItemService.GetAverageCostAsync(id);
var lastPrice = await _stockItemService.GetLastPurchasePriceAsync(id);
return Ok(new StockPricingDto
{
AverageCost = avgCost,
LastPurchasePrice = lastPrice
});
}
[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.SupplierId, dto.UnitPrice, dto.Notes);
return Ok(MapTransactionToDto(transaction));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpPost("{id}/use")]
public async Task<ActionResult<StockTransactionDto>> UseStock(int id, UseStockDto dto)
{
try
{
var transaction = await _stockItemService.UseStockAsync(id, dto.Quantity, dto.JobId, dto.Notes);
return Ok(MapTransactionToDto(transaction));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpPost("{id}/adjust")]
public async Task<ActionResult<StockTransactionDto>> AdjustStock(int id, AdjustStockDto dto)
{
try
{
var transaction = await _stockItemService.AdjustStockAsync(id, dto.NewQuantity, dto.Notes);
return Ok(MapTransactionToDto(transaction));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpPost("{id}/scrap")]
public async Task<ActionResult<StockTransactionDto>> ScrapStock(int id, ScrapStockDto dto)
{
try
{
var transaction = await _stockItemService.ScrapStockAsync(id, dto.Quantity, dto.Notes);
return Ok(MapTransactionToDto(transaction));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpPost("{id}/recalculate")]
public async Task<ActionResult<object>> RecalculateStock(int id)
{
try
{
var newQuantity = await _stockItemService.RecalculateQuantityAsync(id);
return Ok(new { QuantityOnHand = newQuantity });
}
catch (InvalidOperationException)
{
return NotFound();
}
}
private static StockItemDto MapToDto(StockItem s) => new()
{
Id = s.Id,
@@ -237,36 +125,7 @@ public class StockItemsController : ControllerBase
LengthInches = s.LengthInches,
LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches),
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes,
IsActive = s.IsActive
};
private static StockTransactionDto MapTransactionToDto(StockTransaction t) => new()
{
Id = t.Id,
StockItemId = t.StockItemId,
Quantity = t.Quantity,
Type = t.Type.ToString(),
JobId = t.JobId,
JobNumber = t.Job?.JobNumber,
SupplierId = t.SupplierId,
SupplierName = t.Supplier?.Name,
UnitPrice = t.UnitPrice,
Notes = t.Notes,
CreatedAt = t.CreatedAt
};
private static OfferingDto MapOfferingToDto(SupplierOffering o) => new()
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.Supplier?.Name,
StockItemId = o.StockItemId,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes,
IsActive = o.IsActive
};
}
@@ -1,172 +0,0 @@
using CutList.Core.Formatting;
using CutList.Web.Data.Entities;
using CutList.Web.DTOs;
using CutList.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace CutList.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SuppliersController : ControllerBase
{
private readonly SupplierService _supplierService;
public SuppliersController(SupplierService supplierService)
{
_supplierService = supplierService;
}
[HttpGet]
public async Task<ActionResult<List<SupplierDto>>> GetAll([FromQuery] bool includeInactive = false)
{
var suppliers = await _supplierService.GetAllAsync(includeInactive);
return Ok(suppliers.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<SupplierDto>> GetById(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
return Ok(MapToDto(supplier));
}
[HttpPost]
public async Task<ActionResult<SupplierDto>> Create(CreateSupplierDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return BadRequest("Name is required");
var supplier = new Supplier
{
Name = dto.Name,
ContactInfo = dto.ContactInfo,
Notes = dto.Notes
};
await _supplierService.CreateAsync(supplier);
return CreatedAtAction(nameof(GetById), new { id = supplier.Id }, MapToDto(supplier));
}
[HttpPut("{id}")]
public async Task<ActionResult<SupplierDto>> Update(int id, UpdateSupplierDto dto)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
if (dto.Name != null) supplier.Name = dto.Name;
if (dto.ContactInfo != null) supplier.ContactInfo = dto.ContactInfo;
if (dto.Notes != null) supplier.Notes = dto.Notes;
await _supplierService.UpdateAsync(supplier);
return Ok(MapToDto(supplier));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
await _supplierService.DeleteAsync(id);
return NoContent();
}
// --- Offerings ---
[HttpGet("{id}/offerings")]
public async Task<ActionResult<List<OfferingDto>>> GetOfferings(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
var offerings = await _supplierService.GetOfferingsForSupplierAsync(id);
return Ok(offerings.Select(MapOfferingToDto).ToList());
}
[HttpPost("{id}/offerings")]
public async Task<ActionResult<OfferingDto>> CreateOffering(int id, CreateOfferingDto dto)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
var exists = await _supplierService.OfferingExistsAsync(id, dto.StockItemId);
if (exists)
return Conflict("An offering for this supplier and stock item already exists");
var offering = new SupplierOffering
{
SupplierId = id,
StockItemId = dto.StockItemId,
PartNumber = dto.PartNumber,
SupplierDescription = dto.SupplierDescription,
Price = dto.Price,
Notes = dto.Notes
};
await _supplierService.AddOfferingAsync(offering);
// Reload with includes
var created = await _supplierService.GetOfferingByIdAsync(offering.Id);
return CreatedAtAction(nameof(GetOfferings), new { id }, MapOfferingToDto(created!));
}
[HttpPut("{supplierId}/offerings/{offeringId}")]
public async Task<ActionResult<OfferingDto>> UpdateOffering(int supplierId, int offeringId, UpdateOfferingDto dto)
{
var offering = await _supplierService.GetOfferingByIdAsync(offeringId);
if (offering == null || offering.SupplierId != supplierId)
return NotFound();
if (dto.PartNumber != null) offering.PartNumber = dto.PartNumber;
if (dto.SupplierDescription != null) offering.SupplierDescription = dto.SupplierDescription;
if (dto.Price.HasValue) offering.Price = dto.Price;
if (dto.Notes != null) offering.Notes = dto.Notes;
await _supplierService.UpdateOfferingAsync(offering);
return Ok(MapOfferingToDto(offering));
}
[HttpDelete("{supplierId}/offerings/{offeringId}")]
public async Task<IActionResult> DeleteOffering(int supplierId, int offeringId)
{
var offering = await _supplierService.GetOfferingByIdAsync(offeringId);
if (offering == null || offering.SupplierId != supplierId)
return NotFound();
await _supplierService.DeleteOfferingAsync(offeringId);
return NoContent();
}
private static SupplierDto MapToDto(Supplier s) => new()
{
Id = s.Id,
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes,
IsActive = s.IsActive
};
private static OfferingDto MapOfferingToDto(SupplierOffering o) => new()
{
Id = o.Id,
SupplierId = o.SupplierId,
SupplierName = o.Supplier?.Name,
StockItemId = o.StockItemId,
MaterialName = o.StockItem?.Material?.DisplayName,
LengthInches = o.StockItem?.LengthInches,
LengthFormatted = o.StockItem != null ? ArchUnits.FormatFromInches((double)o.StockItem.LengthInches) : null,
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes,
IsActive = o.IsActive
};
}
-23
View File
@@ -3,18 +3,10 @@ namespace CutList.Web.DTOs;
public class CatalogData
{
public DateTime ExportedAt { get; set; }
public List<CatalogSupplierDto> Suppliers { get; set; } = [];
public List<CatalogCuttingToolDto> CuttingTools { get; set; } = [];
public CatalogMaterialsDto Materials { get; set; } = new();
}
public class CatalogSupplierDto
{
public string Name { get; set; } = "";
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class CatalogCuttingToolDto
{
public string Name { get; set; } = "";
@@ -111,32 +103,17 @@ public class CatalogStockItemDto
{
public decimal LengthInches { get; set; }
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public List<CatalogSupplierOfferingDto> SupplierOfferings { get; set; } = [];
}
public class CatalogSupplierOfferingDto
{
public string SupplierName { get; set; } = "";
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class ImportResultDto
{
public int SuppliersCreated { get; set; }
public int SuppliersUpdated { get; set; }
public int CuttingToolsCreated { get; set; }
public int CuttingToolsUpdated { get; set; }
public int MaterialsCreated { get; set; }
public int MaterialsUpdated { get; set; }
public int StockItemsCreated { get; set; }
public int StockItemsUpdated { get; set; }
public int OfferingsCreated { get; set; }
public int OfferingsUpdated { get; set; }
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
}
-50
View File
@@ -8,7 +8,6 @@ public class StockItemDto
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
@@ -18,7 +17,6 @@ public class CreateStockItemDto
public int MaterialId { get; set; }
public string Length { get; set; } = string.Empty;
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
}
@@ -28,51 +26,3 @@ public class UpdateStockItemDto
public string? Name { get; set; }
public string? Notes { get; set; }
}
public class StockTransactionDto
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int Quantity { get; set; }
public string Type { get; set; } = string.Empty;
public int? JobId { get; set; }
public string? JobNumber { get; set; }
public int? SupplierId { get; set; }
public string? SupplierName { get; set; }
public decimal? UnitPrice { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
}
public class AddStockDto
{
public int Quantity { get; set; }
public int? SupplierId { get; set; }
public decimal? UnitPrice { 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; }
}
public class StockPricingDto
{
public decimal? AverageCost { get; set; }
public decimal? LastPurchasePrice { get; set; }
}
-57
View File
@@ -1,57 +0,0 @@
namespace CutList.Web.DTOs;
public class SupplierDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class CreateSupplierDto
{
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class UpdateSupplierDto
{
public string? Name { get; set; }
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class OfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string? SupplierName { get; set; }
public int StockItemId { get; set; }
public string? MaterialName { get; set; }
public decimal? LengthInches { get; set; }
public string? LengthFormatted { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class CreateOfferingDto
{
public int StockItemId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class UpdateOfferingDto
{
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
-87
View File
@@ -12,15 +12,11 @@ public class ApplicationDbContext : DbContext
public DbSet<Material> Materials => Set<Material>();
public DbSet<MaterialDimensions> MaterialDimensions => Set<MaterialDimensions>();
public DbSet<Supplier> Suppliers => Set<Supplier>();
public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<SupplierOffering> SupplierOfferings => Set<SupplierOffering>();
public DbSet<StockTransaction> StockTransactions => Set<StockTransaction>();
public DbSet<CuttingTool> CuttingTools => Set<CuttingTool>();
public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobPart> JobParts => Set<JobPart>();
public DbSet<JobStock> JobStocks => Set<JobStock>();
public DbSet<PurchaseItem> PurchaseItems => Set<PurchaseItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -143,15 +139,6 @@ public class ApplicationDbContext : DbContext
entity.HasIndex(e => e.NominalSize);
});
// Supplier
modelBuilder.Entity<Supplier>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.Property(e => e.ContactInfo).HasMaxLength(500);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
});
// StockItem
modelBuilder.Entity<StockItem>(entity =>
{
@@ -169,52 +156,6 @@ public class ApplicationDbContext : DbContext
entity.HasIndex(e => new { e.MaterialId, e.LengthInches }).IsUnique();
});
// StockTransaction
modelBuilder.Entity<StockTransaction>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.UnitPrice).HasPrecision(10, 2);
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);
entity.HasOne(e => e.Supplier)
.WithMany()
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.SetNull);
});
// SupplierOffering
modelBuilder.Entity<SupplierOffering>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.PartNumber).HasMaxLength(100);
entity.Property(e => e.SupplierDescription).HasMaxLength(255);
entity.Property(e => e.Price).HasPrecision(10, 2);
entity.Property(e => e.Notes).HasMaxLength(255);
entity.HasOne(e => e.StockItem)
.WithMany(s => s.SupplierOfferings)
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany(s => s.Offerings)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => new { e.SupplierId, e.StockItemId }).IsUnique();
});
// CuttingTool
modelBuilder.Entity<CuttingTool>(entity =>
{
@@ -282,34 +223,6 @@ public class ApplicationDbContext : DbContext
.OnDelete(DeleteBehavior.SetNull);
});
// PurchaseItem
modelBuilder.Entity<PurchaseItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.Status)
.HasMaxLength(20)
.HasConversion(
v => v.ToString(),
v => Enum.Parse<PurchaseItemStatus>(v));
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.StockItem)
.WithMany()
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany()
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Job)
.WithMany()
.HasForeignKey(e => e.JobId)
.OnDelete(DeleteBehavior.SetNull);
});
// Seed default cutting tools
modelBuilder.Entity<CuttingTool>().HasData(
new CuttingTool { Id = 1, Name = "Bandsaw", KerfInches = 0.0625m, IsDefault = true, IsActive = true },
-25
View File
@@ -1,25 +0,0 @@
namespace CutList.Web.Data.Entities;
public enum PurchaseItemStatus
{
Pending,
Ordered,
Received
}
public class PurchaseItem
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int? SupplierId { get; set; }
public int Quantity { get; set; }
public int? JobId { get; set; }
public string? Notes { get; set; }
public PurchaseItemStatus Status { get; set; } = PurchaseItemStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public StockItem StockItem { get; set; } = null!;
public Supplier? Supplier { get; set; }
public Job? Job { get; set; }
}
-3
View File
@@ -6,13 +6,10 @@ public class StockItem
public int MaterialId { get; set; }
public decimal LengthInches { get; set; }
public string? Name { get; set; }
public int QuantityOnHand { get; set; } = 0;
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public Material Material { get; set; } = null!;
public ICollection<SupplierOffering> SupplierOfferings { get; set; } = new List<SupplierOffering>();
public ICollection<StockTransaction> Transactions { get; set; } = new List<StockTransaction>();
}
@@ -1,27 +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 int? SupplierId { get; set; }
public decimal? UnitPrice { 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 Supplier? Supplier { get; set; }
}
public enum StockTransactionType
{
Received,
Used,
Adjustment,
Scrapped,
Returned
}
-13
View File
@@ -1,13 +0,0 @@
namespace CutList.Web.Data.Entities;
public class Supplier
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<SupplierOffering> Offerings { get; set; } = new List<SupplierOffering>();
}
@@ -1,16 +0,0 @@
namespace CutList.Web.Data.Entities;
public class SupplierOffering
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int SupplierId { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public StockItem StockItem { get; set; } = null!;
public Supplier Supplier { get; set; } = null!;
}
@@ -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": []
}
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution and project files for restore
COPY CutList.sln .
COPY CutList.Web/CutList.Web.csproj CutList.Web/
COPY CutList.Core/CutList.Core.csproj CutList.Core/
RUN dotnet restore CutList.Web/CutList.Web.csproj
# Copy everything else and build
COPY . .
WORKDIR /src/CutList.Web
RUN dotnet publish -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 5270
ENV ASPNETCORE_URLS=http://+:5270
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "CutList.Web.dll"]
@@ -0,0 +1,684 @@
// <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("20260801124832_RemoveVendorData")]
partial class RemoveVendorData
{
/// <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<int>("QuantityOnHand")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.ToTable("StockTransactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
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.StockTransaction", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("Transactions")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Job");
b.Navigation("StockItem");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Jobs");
});
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");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("Transactions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,183 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class RemoveVendorData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_StockTransactions_Suppliers_SupplierId",
table: "StockTransactions");
migrationBuilder.DropTable(
name: "PurchaseItems");
migrationBuilder.DropTable(
name: "SupplierOfferings");
migrationBuilder.DropTable(
name: "Suppliers");
migrationBuilder.DropIndex(
name: "IX_StockTransactions_SupplierId",
table: "StockTransactions");
migrationBuilder.DropColumn(
name: "SupplierId",
table: "StockTransactions");
migrationBuilder.DropColumn(
name: "UnitPrice",
table: "StockTransactions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SupplierId",
table: "StockTransactions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "UnitPrice",
table: "StockTransactions",
type: "decimal(10,2)",
precision: 10,
scale: 2,
nullable: true);
migrationBuilder.CreateTable(
name: "Suppliers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ContactInfo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Suppliers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PurchaseItems",
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),
SupplierId = table.Column<int>(type: "int", nullable: true),
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),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseItems_Jobs_JobId",
column: x => x.JobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PurchaseItems_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PurchaseItems_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "SupplierOfferings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StockItemId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
PartNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Price = table.Column<decimal>(type: "decimal(10,2)", precision: 10, scale: 2, nullable: true),
SupplierDescription = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SupplierOfferings", x => x.Id);
table.ForeignKey(
name: "FK_SupplierOfferings_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupplierOfferings_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StockTransactions_SupplierId",
table: "StockTransactions",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_JobId",
table: "PurchaseItems",
column: "JobId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_StockItemId",
table: "PurchaseItems",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_SupplierId",
table: "PurchaseItems",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_StockItemId",
table: "SupplierOfferings",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_SupplierOfferings_SupplierId_StockItemId",
table: "SupplierOfferings",
columns: new[] { "SupplierId", "StockItemId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_StockTransactions_Suppliers_SupplierId",
table: "StockTransactions",
column: "SupplierId",
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}
@@ -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-%';
");
}
}
}
@@ -17,7 +17,7 @@ namespace CutList.Web.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -294,54 +294,6 @@ namespace CutList.Web.Migrations
b.UseTpcMappingStrategy();
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", 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<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("PurchaseItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
@@ -373,9 +325,6 @@ namespace CutList.Web.Migrations
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("QuantityOnHand")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@@ -387,129 +336,6 @@ namespace CutList.Web.Migrations
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<decimal?>("UnitPrice")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("StockTransactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("PartNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<string>("SupplierDescription")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId", "StockItemId")
.IsUnique();
b.ToTable("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
@@ -754,31 +580,6 @@ namespace CutList.Web.Migrations
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany()
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
@@ -790,50 +591,6 @@ namespace CutList.Web.Migrations
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("Transactions")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("SupplierOfferings")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Offerings")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Jobs");
@@ -854,18 +611,6 @@ namespace CutList.Web.Migrations
b.Navigation("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("SupplierOfferings");
b.Navigation("Transactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Offerings");
});
#pragma warning restore 612, 618
}
}
+1 -2
View File
@@ -25,12 +25,11 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
// Add application services
builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<OverviewService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<PurchaseItemService>();
builder.Services.AddScoped<CatalogService>();
var app = builder.Build();
+23 -150
View File
@@ -20,12 +20,6 @@ public class CatalogService
{
await using var context = _factory.CreateDbContext();
var suppliers = await context.Suppliers
.Where(s => s.IsActive)
.OrderBy(s => s.Name)
.AsNoTracking()
.ToListAsync();
var cuttingTools = await context.CuttingTools
.Where(t => t.IsActive)
.OrderBy(t => t.Name)
@@ -35,7 +29,6 @@ public class CatalogService
var materials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems.Where(s => s.IsActive))
.ThenInclude(s => s.SupplierOfferings.Where(o => o.IsActive))
.Where(m => m.IsActive)
.OrderBy(m => m.Shape).ThenBy(m => m.SortOrder)
.AsNoTracking()
@@ -48,7 +41,7 @@ public class CatalogService
{
foreach (var m in group)
{
var stockItems = MapStockItems(m, suppliers);
var stockItems = MapStockItems(m);
switch (m.Shape)
{
@@ -139,12 +132,6 @@ public class CatalogService
return new CatalogData
{
ExportedAt = DateTime.UtcNow,
Suppliers = suppliers.Select(s => new CatalogSupplierDto
{
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes
}).ToList(),
CuttingTools = cuttingTools.Select(t => new CatalogCuttingToolDto
{
Name = t.Name,
@@ -164,14 +151,11 @@ public class CatalogService
try
{
// 1. Suppliers - upsert by name
var supplierMap = await ImportSuppliersAsync(context, data.Suppliers, result);
// 2. Cutting tools - upsert by name
// 1. Cutting tools - upsert by name
await ImportCuttingToolsAsync(context, data.CuttingTools, result);
// 3. Materials + stock items + offerings
await ImportAllMaterialsAsync(context, data.Materials, supplierMap, result);
// 2. Materials + stock items
await ImportAllMaterialsAsync(context, data.Materials, result);
await transaction.CommitAsync();
}
@@ -184,54 +168,6 @@ public class CatalogService
return result;
}
private async Task<Dictionary<string, int>> ImportSuppliersAsync(
ApplicationDbContext context, List<CatalogSupplierDto> suppliers, ImportResultDto result)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var existingSuppliers = await context.Suppliers.ToListAsync();
foreach (var dto in suppliers)
{
try
{
var existing = existingSuppliers.FirstOrDefault(
s => s.Name.Equals(dto.Name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.ContactInfo = dto.ContactInfo ?? existing.ContactInfo;
existing.Notes = dto.Notes ?? existing.Notes;
existing.IsActive = true;
map[dto.Name] = existing.Id;
result.SuppliersUpdated++;
}
else
{
var supplier = new Supplier
{
Name = dto.Name,
ContactInfo = dto.ContactInfo,
Notes = dto.Notes,
CreatedAt = DateTime.UtcNow
};
context.Suppliers.Add(supplier);
await context.SaveChangesAsync();
existingSuppliers.Add(supplier);
map[dto.Name] = supplier.Id;
result.SuppliersCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Supplier '{dto.Name}': {ex.Message}");
}
}
await context.SaveChangesAsync();
return map;
}
private async Task ImportCuttingToolsAsync(
ApplicationDbContext context, List<CatalogCuttingToolDto> tools, ImportResultDto result)
{
@@ -273,68 +209,67 @@ public class CatalogService
}
private async Task ImportAllMaterialsAsync(
ApplicationDbContext context, CatalogMaterialsDto materials, Dictionary<string, int> supplierMap, ImportResultDto result)
ApplicationDbContext context, CatalogMaterialsDto materials, ImportResultDto result)
{
var existingMaterials = await context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems)
.ThenInclude(s => s.SupplierOfferings)
.ToListAsync();
foreach (var dto in materials.Angles)
await ImportMaterialAsync(context, dto, MaterialShape.Angle, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Angle, existingMaterials, result,
() => new AngleDimensions { Leg1 = dto.Leg1, Leg2 = dto.Leg2, Thickness = dto.Thickness },
dim => { var d = (AngleDimensions)dim; d.Leg1 = dto.Leg1; d.Leg2 = dto.Leg2; d.Thickness = dto.Thickness; });
foreach (var dto in materials.Channels)
await ImportMaterialAsync(context, dto, MaterialShape.Channel, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Channel, existingMaterials, result,
() => new ChannelDimensions { Height = dto.Height, Flange = dto.Flange, Web = dto.Web },
dim => { var d = (ChannelDimensions)dim; d.Height = dto.Height; d.Flange = dto.Flange; d.Web = dto.Web; });
foreach (var dto in materials.FlatBars)
await ImportMaterialAsync(context, dto, MaterialShape.FlatBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.FlatBar, existingMaterials, result,
() => new FlatBarDimensions { Width = dto.Width, Thickness = dto.Thickness },
dim => { var d = (FlatBarDimensions)dim; d.Width = dto.Width; d.Thickness = dto.Thickness; });
foreach (var dto in materials.IBeams)
await ImportMaterialAsync(context, dto, MaterialShape.IBeam, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.IBeam, existingMaterials, result,
() => new IBeamDimensions { Height = dto.Height, WeightPerFoot = dto.WeightPerFoot },
dim => { var d = (IBeamDimensions)dim; d.Height = dto.Height; d.WeightPerFoot = dto.WeightPerFoot; });
foreach (var dto in materials.Pipes)
await ImportMaterialAsync(context, dto, MaterialShape.Pipe, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.Pipe, existingMaterials, result,
() => new PipeDimensions { NominalSize = dto.NominalSize, Wall = dto.Wall, Schedule = dto.Schedule },
dim => { var d = (PipeDimensions)dim; d.NominalSize = dto.NominalSize; d.Wall = (decimal?)dto.Wall; d.Schedule = dto.Schedule; });
foreach (var dto in materials.RectangularTubes)
await ImportMaterialAsync(context, dto, MaterialShape.RectangularTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RectangularTube, existingMaterials, result,
() => new RectangularTubeDimensions { Width = dto.Width, Height = dto.Height, Wall = dto.Wall },
dim => { var d = (RectangularTubeDimensions)dim; d.Width = dto.Width; d.Height = dto.Height; d.Wall = dto.Wall; });
foreach (var dto in materials.RoundBars)
await ImportMaterialAsync(context, dto, MaterialShape.RoundBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RoundBar, existingMaterials, result,
() => new RoundBarDimensions { Diameter = dto.Diameter },
dim => { var d = (RoundBarDimensions)dim; d.Diameter = dto.Diameter; });
foreach (var dto in materials.RoundTubes)
await ImportMaterialAsync(context, dto, MaterialShape.RoundTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.RoundTube, existingMaterials, result,
() => new RoundTubeDimensions { OuterDiameter = dto.OuterDiameter, Wall = dto.Wall },
dim => { var d = (RoundTubeDimensions)dim; d.OuterDiameter = dto.OuterDiameter; d.Wall = dto.Wall; });
foreach (var dto in materials.SquareBars)
await ImportMaterialAsync(context, dto, MaterialShape.SquareBar, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.SquareBar, existingMaterials, result,
() => new SquareBarDimensions { Size = dto.SideLength },
dim => { var d = (SquareBarDimensions)dim; d.Size = dto.SideLength; });
foreach (var dto in materials.SquareTubes)
await ImportMaterialAsync(context, dto, MaterialShape.SquareTube, existingMaterials, supplierMap, result,
await ImportMaterialAsync(context, dto, MaterialShape.SquareTube, existingMaterials, result,
() => new SquareTubeDimensions { Size = dto.SideLength, Wall = dto.Wall },
dim => { var d = (SquareTubeDimensions)dim; d.Size = dto.SideLength; d.Wall = dto.Wall; });
}
private async Task ImportMaterialAsync(
ApplicationDbContext context, CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials, Dictionary<string, int> supplierMap,
List<Material> existingMaterials,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
@@ -389,7 +324,7 @@ public class CatalogService
await context.SaveChangesAsync();
await ImportStockItemsAsync(context, material, dto.StockItems, supplierMap, result);
await ImportStockItemsAsync(context, material, dto.StockItems, result);
}
catch (Exception ex)
{
@@ -399,10 +334,9 @@ public class CatalogService
private async Task ImportStockItemsAsync(
ApplicationDbContext context, Material material, List<CatalogStockItemDto> stockItems,
Dictionary<string, int> supplierMap, ImportResultDto result)
ImportResultDto result)
{
var existingStockItems = await context.StockItems
.Include(s => s.SupplierOfferings)
.Where(s => s.MaterialId == material.Id)
.ToListAsync();
@@ -413,25 +347,21 @@ public class CatalogService
var existing = existingStockItems.FirstOrDefault(
s => s.LengthInches == dto.LengthInches);
StockItem stockItem;
if (existing != null)
{
existing.Name = dto.Name ?? existing.Name;
existing.Notes = dto.Notes ?? existing.Notes;
existing.IsActive = true;
existing.UpdatedAt = DateTime.UtcNow;
stockItem = existing;
result.StockItemsUpdated++;
}
else
{
stockItem = new StockItem
var stockItem = new StockItem
{
MaterialId = material.Id,
LengthInches = dto.LengthInches,
Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes,
CreatedAt = DateTime.UtcNow
};
@@ -440,56 +370,6 @@ public class CatalogService
existingStockItems.Add(stockItem);
result.StockItemsCreated++;
}
foreach (var offeringDto in dto.SupplierOfferings)
{
try
{
if (!supplierMap.TryGetValue(offeringDto.SupplierName, out var supplierId))
{
result.Warnings.Add(
$"Offering for stock '{material.DisplayName} @ {dto.LengthInches}\"': " +
$"Unknown supplier '{offeringDto.SupplierName}', skipped");
continue;
}
var existingOffering = stockItem.SupplierOfferings.FirstOrDefault(
o => o.SupplierId == supplierId);
if (existingOffering != null)
{
existingOffering.PartNumber = offeringDto.PartNumber ?? existingOffering.PartNumber;
existingOffering.SupplierDescription = offeringDto.SupplierDescription ?? existingOffering.SupplierDescription;
existingOffering.Price = offeringDto.Price ?? existingOffering.Price;
existingOffering.Notes = offeringDto.Notes ?? existingOffering.Notes;
existingOffering.IsActive = true;
result.OfferingsUpdated++;
}
else
{
var offering = new SupplierOffering
{
StockItemId = stockItem.Id,
SupplierId = supplierId,
PartNumber = offeringDto.PartNumber,
SupplierDescription = offeringDto.SupplierDescription,
Price = offeringDto.Price,
Notes = offeringDto.Notes
};
context.SupplierOfferings.Add(offering);
stockItem.SupplierOfferings.Add(offering);
result.OfferingsCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add(
$"Offering for '{material.DisplayName} @ {dto.LengthInches}\"' " +
$"from '{offeringDto.SupplierName}': {ex.Message}");
}
}
await context.SaveChangesAsync();
}
catch (Exception ex)
{
@@ -497,24 +377,17 @@ public class CatalogService
$"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}");
}
}
await context.SaveChangesAsync();
}
private static List<CatalogStockItemDto> MapStockItems(Material m, List<Supplier> suppliers)
private static List<CatalogStockItemDto> MapStockItems(Material m)
{
return m.StockItems.OrderBy(s => s.LengthInches).Select(s => new CatalogStockItemDto
{
LengthInches = s.LengthInches,
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes,
SupplierOfferings = s.SupplierOfferings.Select(o => new CatalogSupplierOfferingDto
{
SupplierName = suppliers.FirstOrDefault(sup => sup.Id == o.SupplierId)?.Name ?? "Unknown",
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes
}).ToList()
Notes = s.Notes
}).ToList();
}
}
+18 -37
View File
@@ -46,10 +46,10 @@ public class CutListPackingService
// Build stock bins
var stockBins = new List<StockBinSource>();
// Check if job has specific stock configured for this material
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0)
// Use job-specific stock configuration. Jobs must have stock explicitly configured -
// there is no fallback to "whatever's in inventory."
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock))
{
// Use job-specific stock configuration
foreach (var stock in materialJobStock.OrderBy(s => s.Priority))
{
stockBins.Add(new StockBinSource
@@ -61,37 +61,6 @@ public class CutListPackingService
});
}
}
else
{
// No job-specific stock - use all available stock items for this material
var stockItems = await context.StockItems
.Where(s => s.MaterialId == materialId && s.IsActive)
.ToListAsync();
foreach (var stock in stockItems)
{
if (stock.QuantityOnHand > 0)
{
// In-stock with finite quantity
stockBins.Add(new StockBinSource
{
LengthInches = stock.LengthInches,
Quantity = stock.QuantityOnHand,
Priority = 1,
IsInStock = true
});
}
// Always add as purchasable (unlimited) - algorithm will use in-stock first due to priority
stockBins.Add(new StockBinSource
{
LengthInches = stock.LengthInches,
Quantity = -1, // unlimited
Priority = 2,
IsInStock = false
});
}
}
if (stockBins.Count == 0)
{
@@ -139,7 +108,14 @@ public class CutListPackingService
var inStockBins = new List<Bin>();
var toBePurchasedBins = new List<Bin>();
// Track remaining in-stock quantities from the stock bins we configured
// Catalog-sourced stock rows with an unlimited (-1) quantity are always in-stock,
// regardless of how many bins of that length get packed.
var unlimitedInStockLengths = stockBins
.Where(s => s.IsInStock && s.Quantity == -1)
.Select(s => s.LengthInches)
.ToHashSet();
// Track remaining in-stock quantities from the finite-quantity catalog stock bins we configured
var remainingStock = stockBins
.Where(s => s.IsInStock && s.Quantity > 0)
.GroupBy(s => s.LengthInches)
@@ -149,9 +125,14 @@ public class CutListPackingService
{
var binLength = (decimal)bin.Length;
// Check if this can come from in-stock
if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
if (unlimitedInStockLengths.Contains(binLength))
{
// Unlimited catalog-sourced stock - always in-stock
inStockBins.Add(bin);
}
else if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
{
// Check if this can come from in-stock
inStockBins.Add(bin);
remainingStock[binLength] = remaining - 1;
}
+9 -24
View File
@@ -42,35 +42,17 @@ public class JobService
{
await using var context = _factory.CreateDbContext();
job ??= new Job();
job.JobNumber = await GenerateJobNumberAsync(context);
job.JobNumber = CreateTemporaryJobNumber();
job.CreatedAt = DateTime.UtcNow;
context.Jobs.Add(job);
await context.SaveChangesAsync();
job.JobNumber = $"JOB-{job.Id}";
await context.SaveChangesAsync();
return job;
}
public async Task<string> GenerateJobNumberAsync()
{
await using var context = _factory.CreateDbContext();
return await GenerateJobNumberAsync(context);
}
private static async Task<string> GenerateJobNumberAsync(ApplicationDbContext context)
{
var maxNumber = await context.Jobs
.Where(j => j.JobNumber.StartsWith("JOB-"))
.Select(j => j.JobNumber)
.MaxAsync() as string;
if (maxNumber == null)
return "JOB-00001";
var numPart = maxNumber.Substring(4);
if (int.TryParse(numPart, out var num))
return $"JOB-{num + 1:D5}";
return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}";
}
private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
public async Task<Job> QuickCreateAsync(string? customer = null)
{
@@ -142,7 +124,7 @@ public class JobService
var duplicate = new Job
{
JobNumber = await GenerateJobNumberAsync(context),
JobNumber = CreateTemporaryJobNumber(),
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
Customer = original.Customer,
CuttingToolId = original.CuttingToolId,
@@ -153,6 +135,9 @@ public class JobService
context.Jobs.Add(duplicate);
await context.SaveChangesAsync();
duplicate.JobNumber = $"JOB-{duplicate.Id}";
await context.SaveChangesAsync();
// Copy parts
foreach (var part in original.Parts)
{
+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);
-119
View File
@@ -1,119 +0,0 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class PurchaseItemService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public PurchaseItemService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<List<PurchaseItem>> GetAllAsync(PurchaseItemStatus? status = null)
{
await using var context = _factory.CreateDbContext();
var query = context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.AsQueryable();
if (status.HasValue)
{
query = query.Where(p => p.Status == status.Value);
}
return await query
.OrderBy(p => p.Status)
.ThenByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<PurchaseItem?> GetByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<PurchaseItem> CreateAsync(PurchaseItem item)
{
await using var context = _factory.CreateDbContext();
item.CreatedAt = DateTime.UtcNow;
context.PurchaseItems.Add(item);
await context.SaveChangesAsync();
return item;
}
public async Task CreateBulkAsync(List<PurchaseItem> items)
{
await using var context = _factory.CreateDbContext();
var now = DateTime.UtcNow;
foreach (var item in items)
{
item.CreatedAt = now;
}
context.PurchaseItems.AddRange(items);
await context.SaveChangesAsync();
}
public async Task UpdateAsync(PurchaseItem item)
{
await using var context = _factory.CreateDbContext();
item.UpdatedAt = DateTime.UtcNow;
context.PurchaseItems.Update(item);
await context.SaveChangesAsync();
}
public async Task UpdateStatusAsync(int id, PurchaseItemStatus status)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.Status = status;
item.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
public async Task UpdateSupplierAsync(int id, int? supplierId)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.SupplierId = supplierId;
item.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
}
}
public async Task DeleteAsync(int id)
{
await using var context = _factory.CreateDbContext();
var item = await context.PurchaseItems.FindAsync(id);
if (item != null)
{
context.PurchaseItems.Remove(item);
await context.SaveChangesAsync();
}
}
}
-176
View File
@@ -57,8 +57,6 @@ public class StockItemService
return await context.StockItems
.Include(s => s.Material)
.Include(s => s.SupplierOfferings)
.ThenInclude(o => o.Supplier)
.FirstOrDefaultAsync(s => s.Id == id);
}
@@ -110,178 +108,4 @@ public class StockItemService
return await query.AnyAsync();
}
// Stock transaction methods
public async Task<StockTransaction> AddStockAsync(int stockItemId, int quantity, int? supplierId = null, decimal? unitPrice = 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.Received,
SupplierId = supplierId,
UnitPrice = unitPrice,
Notes = notes,
CreatedAt = DateTime.UtcNow
};
stockItem.QuantityOnHand += quantity;
stockItem.UpdatedAt = DateTime.UtcNow;
context.StockTransactions.Add(transaction);
await context.SaveChangesAsync();
return transaction;
}
public async Task<decimal?> GetAverageCostAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
var transactions = await context.StockTransactions
.Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue)
.ToListAsync();
if (transactions.Count == 0)
return null;
var totalCost = transactions.Sum(t => t.Quantity * t.UnitPrice!.Value);
var totalQty = transactions.Sum(t => t.Quantity);
return totalQty > 0 ? totalCost / totalQty : null;
}
public async Task<decimal?> GetLastPurchasePriceAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
return await context.StockTransactions
.Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue)
.OrderByDescending(t => t.CreatedAt)
.Select(t => t.UnitPrice)
.FirstOrDefaultAsync();
}
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)
.Include(t => t.Supplier)
.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;
}
}
-139
View File
@@ -1,139 +0,0 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class SupplierService
{
private readonly IDbContextFactory<ApplicationDbContext> _factory;
public SupplierService(IDbContextFactory<ApplicationDbContext> factory)
{
_factory = factory;
}
public async Task<List<Supplier>> GetAllAsync(bool includeInactive = false)
{
await using var context = _factory.CreateDbContext();
var query = context.Suppliers.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
return await query.OrderBy(s => s.Name).ToListAsync();
}
public async Task<Supplier?> GetByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.Suppliers
.Include(s => s.Offerings)
.ThenInclude(o => o.StockItem)
.ThenInclude(si => si.Material)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<Supplier> CreateAsync(Supplier supplier)
{
await using var context = _factory.CreateDbContext();
supplier.CreatedAt = DateTime.UtcNow;
context.Suppliers.Add(supplier);
await context.SaveChangesAsync();
return supplier;
}
public async Task UpdateAsync(Supplier supplier)
{
await using var context = _factory.CreateDbContext();
context.Suppliers.Update(supplier);
await context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
await using var context = _factory.CreateDbContext();
var supplier = await context.Suppliers.FindAsync(id);
if (supplier != null)
{
supplier.IsActive = false;
await context.SaveChangesAsync();
}
}
// Offering management
public async Task<List<SupplierOffering>> GetOfferingsForSupplierAsync(int supplierId)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.StockItem)
.ThenInclude(si => si.Material)
.Where(o => o.SupplierId == supplierId && o.IsActive)
.OrderBy(o => o.StockItem.Material.Shape)
.ThenBy(o => o.StockItem.Material.Size)
.ThenBy(o => o.StockItem.LengthInches)
.ToListAsync();
}
public async Task<List<SupplierOffering>> GetOfferingsForStockItemAsync(int stockItemId)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.Supplier)
.Where(o => o.StockItemId == stockItemId && o.IsActive && o.Supplier.IsActive)
.OrderBy(o => o.Supplier.Name)
.ToListAsync();
}
public async Task<SupplierOffering?> GetOfferingByIdAsync(int id)
{
await using var context = _factory.CreateDbContext();
return await context.SupplierOfferings
.Include(o => o.StockItem)
.ThenInclude(si => si.Material)
.Include(o => o.Supplier)
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task<SupplierOffering> AddOfferingAsync(SupplierOffering offering)
{
await using var context = _factory.CreateDbContext();
context.SupplierOfferings.Add(offering);
await context.SaveChangesAsync();
return offering;
}
public async Task UpdateOfferingAsync(SupplierOffering offering)
{
await using var context = _factory.CreateDbContext();
context.SupplierOfferings.Update(offering);
await context.SaveChangesAsync();
}
public async Task DeleteOfferingAsync(int id)
{
await using var context = _factory.CreateDbContext();
var offering = await context.SupplierOfferings.FindAsync(id);
if (offering != null)
{
offering.IsActive = false;
await context.SaveChangesAsync();
}
}
public async Task<bool> OfferingExistsAsync(int supplierId, int stockItemId, int? excludeId = null)
{
await using var context = _factory.CreateDbContext();
var query = context.SupplierOfferings.Where(o =>
o.SupplierId == supplierId &&
o.StockItemId == stockItemId &&
o.IsActive);
if (excludeId.HasValue)
{
query = query.Where(o => o.Id != excludeId.Value);
}
return await query.AnyAsync();
}
}
+95 -3
View File
@@ -88,8 +88,8 @@ main {
}
.top-row {
background-color: #f3f1ee;
border-bottom: 1px solid #e0ddd8;
background-color: #edf2f5;
border-bottom: 1px solid #d4dee5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
@@ -145,7 +145,7 @@ main {
width: 0;
}
.top-row, article {
article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
@@ -225,3 +225,95 @@ h1 {
text-align: left;
}
}
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
:root {
--ink: #17212b;
--muted: #617282;
--paper: #f3f6f8;
--paper-bright: #ffffff;
--line: #d4dee5;
--accent: #2b75a8;
--accent-dark: #1d5d8d;
--navy: #203747;
--rail: #18242e;
}
html, body { background: var(--paper); color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { letter-spacing: -.01em; }
main { background: var(--paper); box-shadow: none; min-width: 0; }
.sidebar { background: var(--rail); border-right: 1px solid #314653; }
.content { margin: 0 auto; max-width: 1440px; padding: 2.75rem clamp(1.5rem, 4vw, 4.5rem) 4rem; }
.eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 650; letter-spacing: -.025em; }
h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem); line-height: 1.04; }
h2 { font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.12; }
.job-title { font-size: clamp(1.5rem, 2.1vw, 2.1rem); line-height: 1.12; }
a, .btn-link { color: var(--accent-dark); }
.btn { border-radius: .35rem; font-size: .85rem; font-weight: 750; letter-spacing: .01em; padding: .65rem 1rem; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
.btn-success { background: #267c6c; border-color: #267c6c; }
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
.card-header { background: #edf2f5; }
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
.table > :not(caption) > * > * { padding: .85rem .75rem; }
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
.alert { border-radius: .4rem; border-width: 1px; }
/* Results cut badges keep the part number and its length visually distinct. */
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
.cut-part-badge-name { padding: .35em .55em; }
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
/* Overview */
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
.overview-recent-jobs { min-width: 0; }
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
/* Dashboard */
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
.dashboard-hero .eyebrow { color: #a8c5d7; }
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
.btn-quiet:hover { color: #8fc5e4; }
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
.dashboard-section { margin-top: 4rem; }
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
.workflow-list li { align-items: center; border-bottom: 1px solid #c3d2dc; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
.workflow-list li > span { align-items: center; background: var(--accent); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
.workflow-list strong, .workflow-list small { display: block; }.workflow-list strong { font-size: .9rem; }.workflow-list small { color: var(--muted); font-size: .78rem; margin-top: .1rem; }
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
+40 -4
View File
@@ -141,14 +141,27 @@
display: none;
}
/* The selected tool belongs on the paper cut report, not in the interactive results layout. */
.print-cut-method {
display: none;
}
/* Print styles - Compact layout to save paper */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
-webkit-print-color-adjust: economy;
print-color-adjust: economy;
font-size: 10pt;
}
*,
*::before,
*::after {
background: transparent !important;
box-shadow: none !important;
}
.print-screen-only,
.sidebar,
.top-row,
.page > main > .top-row,
@@ -304,8 +317,16 @@
margin: 0 !important;
}
/* Keep purchase list with cut lists to save paper */
.print-purchase-list {
.print-cut-method {
display: block !important;
border: 1px solid #ccc;
font-size: 9pt;
margin: -0.25rem 0 0.5rem;
padding: 0.3rem 0.75rem;
}
/* Keep material list with cut lists to save paper */
.print-material-list {
break-inside: avoid;
page-break-inside: avoid;
}
@@ -329,6 +350,15 @@
border: 1px solid #999;
}
/* Badges lose their blue fills in print, so force their white screen text and divider to black. */
.cut-part-badge {
color: #000 !important;
}
.cut-part-badge-divider {
border-left: 1px solid #000 !important;
}
/* Cut list tables: hide screen header, show repeating print header in thead */
.cutlist-material-screen-header {
display: none !important;
@@ -365,6 +395,12 @@
padding: 0 !important;
}
/* Keep each stock-bar result, its cuts, and its waste on one printed page. */
.cutlist-material-card tbody tr {
break-inside: avoid;
page-break-inside: avoid;
}
/* Reduce spacing */
.mb-4 {
margin-bottom: 0.5rem !important;
+14
View File
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -69,6 +71,18 @@ Global
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.ActiveCfg = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.Build.0 = Debug|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.Build.0 = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.ActiveCfg = Release|Any CPU
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
File diff suppressed because it is too large Load Diff
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,45 @@
# Remove vendor/supplier data from CutList
## Background
CutList's stated purpose is a 1D bin-packing optimizer: given a parts list and available stock, compute an efficient cutting plan. Supplier/vendor management (contacts, part numbers, pricing, purchase orders) was built alongside it but never touched by the packing engine itself (`AdvancedFitEngine`, `MultiBinEngine`, `CutListPackingService` never reference `Supplier`). It only entered through inventory-management pages.
This surfaced concretely in the Alro Steel catalog scraper: every scraped stock item was wrapped in a `supplierOfferings` array with empty `partNumber`/`supplierDescription` placeholders (Alro's SmartGrid never exposes part numbers), making it needlessly verbose to add or change a stock length. Investigating further surfaced the actual reported bug — a user (Carrol) couldn't figure out how to change an existing stock item's length, because `Stock/Edit.razor` hard-locks the Length field to `readonly` outside of creation.
Decision: fix the length-editing bug, and separately, pull vendor/purchasing concepts out of CutList entirely. Procurement (who to buy from, at what price, PO status) is a different domain than nesting optimization. If a "what should we order" feature is needed later, it can live in a layer above CutList that queries the existing Materials/StockItems REST API (`MaterialsController`, `StockItemsController`) to compare `JobStock` requirements against `StockItem.QuantityOnHand` — nothing in this change forecloses that.
## Scope
### Kept in CutList (nesting/inventory-relevant)
- **`StockItem.QuantityOnHand`** — becomes directly editable via a stripped-down `StockTransaction` log:
- Keeps: `Quantity`, `Type` (`Received`/`Used`/`Adjustment`/`Scrapped`; `Returned` is dead code today — never constructed anywhere, only referenced in a UI badge-color switch — and stays dead, out of scope to touch), `JobId`, `Notes`, `CreatedAt`.
- Drops: `SupplierId`, `UnitPrice`, and the `Supplier` navigation.
- `StockItemService` keeps `AddStockAsync/UseStockAsync/AdjustStockAsync/ScrapStockAsync/GetTransactionHistoryAsync/RecalculateQuantityAsync`, with `supplierId`/`unitPrice` parameters removed from `AddStockAsync`.
- Drops `GetAverageCostAsync`/`GetLastPurchasePriceAsync` and `StockPricingDto` entirely — there's no non-vendor version of "cost."
- **`Job.LockedAt`/`IsLocked`** — kept. Becomes a direct manual Lock/Unlock action instead of a side effect of "Add to Order List." Still production-relevant: don't let someone edit a parts list after material's been committed/cut against it.
- **Bug fix**: `Stock/Edit.razor`'s Length field becomes editable in both create and edit modes. The backend already supports this — `StockItemService.ExistsAsync` already excludes the current row's ID for the edit case in its `(MaterialId, LengthInches)` uniqueness check — so this is a front-end-only fix.
### Removed entirely (procurement layer, not CutList's job)
- **Entities**: `Supplier`, `SupplierOffering`, `PurchaseItem`.
- **Services/Controllers/DTOs**: `SupplierService`, `PurchaseItemService`, `SuppliersController`, `SupplierDtos.cs`. `StockItemsController` drops its `/offerings` and `/pricing` endpoints.
- **Pages**: `Suppliers/Index.razor`, `Suppliers/Edit.razor`, `Orders/Index.razor`, `Orders/Add.razor`. `NavMenu.razor` loses the Suppliers and Orders links. `Home.razor` loses the Suppliers dashboard card and supplier/order mentions in the getting-started copy.
- **`Jobs/Edit.razor`**: "Add to Order List" button (which created `PurchaseItem`s and locked the job) is replaced with a plain "Lock Job" button that calls `JobService.LockAsync` directly. Drops the `PurchaseItemService` dependency and bulk-create logic.
- **Catalog import/export**: `CatalogDtos.cs` drops `CatalogSupplierDto`, `CatalogSupplierOfferingDto`, `CatalogData.Suppliers`, and `CatalogStockItemDto.SupplierOfferings`. `CatalogService` drops `ImportSuppliersAsync` and all offering-import logic. Stock items in the catalog format become `{ lengthInches, quantityOnHand }`.
- **Seed data**: `alro-catalog.json` already regenerated without vendor wrappers. `oneals-catalog.json` gets the same treatment via a one-off transform script (not checked in as a permanent tool) — it currently has *real* O'Neal part numbers/prices, which will be discarded along with the empty Alro placeholders, for consistency.
- **`CutList.Mcp`**: removes `list_suppliers`, `add_supplier`, `list_supplier_offerings`, `add_supplier_offering`, `add_stock_with_offering` (tools, DTOs, and the corresponding `ApiClient` methods). Adds a plain `add_stock` tool: find-or-create material → find-or-create stock item → set quantity — same convenience flow as `add_stock_with_offering` minus the offering step. Republished to `~/.claude/mcp/CutList.Mcp/` per the standard MCP publishing workflow.
### Migration
One new EF Core migration:
- Drops `Suppliers`, `SupplierOfferings`, `PurchaseItems` tables.
- Drops `SupplierId`/`UnitPrice` columns and the associated FK from `StockTransactions`.
This is destructive to whatever currently lives in those tables (e.g., any real Supplier/Offering/PurchaseItem rows in the dev DB). Applied immediately after creation per the usual EF workflow, no separate confirmation gate beyond this design doc.
## Out of scope
- Building the "layer above" that would query stock levels for purchasing decisions — not needed now, and the existing Materials/StockItems REST API already exposes what such a layer would need later.
- Any change to the packing algorithm itself (`CutList.Core`) — untouched by this work, confirming it never depended on vendor data in the first place.
- Removing the dead `StockTransactionType.Returned` enum value — unused, harmless, unrelated to vendor data.
@@ -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()