Compare commits
13
Commits
1f04c5343a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1564451d | ||
|
|
5409479e00 | ||
|
|
25af9e3ba8 | ||
|
|
3ccaddef0c | ||
|
|
79bf56afd8 | ||
|
|
96f5824d12 | ||
|
|
db054aa6dc | ||
|
|
8e2027d81b | ||
|
|
57c7aa5f9f | ||
|
|
34c9b77501 | ||
|
|
203ea0c69d | ||
|
|
5cbd06711c | ||
|
|
f3b911f37c |
@@ -12,9 +12,9 @@ The solution contains four projects:
|
||||
|
||||
| Project | Framework | Purpose |
|
||||
|---------|-----------|---------|
|
||||
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
||||
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
||||
| **CutList** | .NET 10.0 Windows Forms | Original desktop UI (MVP pattern) |
|
||||
| **CutList.Core** | .NET 10.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
|
||||
| **CutList.Web** | .NET 10.0 Blazor Server | Web-based UI + REST API, EF Core + SQL Server |
|
||||
| **CutList.Mcp** | .NET 10.0 Console (stdio) | MCP server exposing CutList.Web's REST API as tools for Claude |
|
||||
|
||||
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI), ModelContextProtocol SDK (CutList.Mcp)
|
||||
@@ -101,6 +101,8 @@ CutList.Mcp is an stdio MCP server, not a hosted service — it's published to `
|
||||
|
||||
**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
|
||||
@@ -214,7 +216,8 @@ Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its
|
||||
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
|
||||
- **Job stock** — Jobs must have stock explicitly configured (catalog-sourced `StockItem` rows or custom-length rows); there is no fallback to auto-discovered inventory
|
||||
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
|
||||
- **Job lock flow** — Optimize job -> Lock Job (manual action, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
||||
- **Job lock flow** — Optimize job -> review/print results -> Lock Job (manual action beside Print Report on the Results tab, available whether or not purchases are needed) -> job becomes read-only until Unlock
|
||||
- **Printed cut badges** — Print styles intentionally remove color fills; cut badges therefore force black text and a black part-number/length divider so both remain legible on paper. The print-only tool line shows the selected cut method and kerf immediately below the summary.
|
||||
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
|
||||
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
|
||||
- **Priority system** — Lower priority bins used first in packing algorithm
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using CutList.Core.Formatting;
|
||||
using Xunit;
|
||||
|
||||
namespace CutList.Core.Tests;
|
||||
|
||||
public class ResultsLengthDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(150, "150\"")]
|
||||
[InlineData(150.375, "150-3/8\"")]
|
||||
public void FormatInches_uses_total_inches_without_converting_to_feet(double inches, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ArchUnits.FormatInches(inches));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Results_markup_includes_unit_toggle_and_divider_between_part_and_length()
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/Components/Pages/Jobs/Edit.razor"));
|
||||
var markup = File.ReadAllText(sourcePath);
|
||||
|
||||
Assert.Contains("FormatResultLength", markup);
|
||||
Assert.Contains("cut-part-badge-divider", markup);
|
||||
Assert.Contains("Show feet + inches", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Printed_cut_list_rows_are_kept_together()
|
||||
{
|
||||
var sourcePath = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, "../../../../CutList.Web/wwwroot/css/report.css"));
|
||||
var css = File.ReadAllText(sourcePath);
|
||||
|
||||
Assert.Contains(".cutlist-material-card tbody tr", css);
|
||||
Assert.Contains("break-inside: avoid", css);
|
||||
Assert.Contains("page-break-inside: avoid", css);
|
||||
}
|
||||
}
|
||||
@@ -80,5 +80,13 @@ namespace CutList.Core.Formatting
|
||||
return $"{inches}\"";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a measurement as a mixed fraction of total inches without converting to feet.
|
||||
/// </summary>
|
||||
public static string FormatInches(double totalInches)
|
||||
{
|
||||
return $"{FormatHelper.ConvertToMixedFraction(totalInches)}\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +6,6 @@
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<header class="workspace-header">
|
||||
<div class="workspace-context"><span class="workspace-eyebrow">Cut planning workspace</span></div>
|
||||
<div class="workspace-status"><span class="status-dot"></span>Ready to plan</div>
|
||||
</header>
|
||||
<article class="content">
|
||||
@Body
|
||||
</article>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.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: Georgia, 'Times New Roman', serif; font-size: 1.45rem; font-weight: 700; letter-spacing: -.06em; line-height: .9; text-decoration: none; }
|
||||
.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; }
|
||||
|
||||
@@ -1,49 +1,148 @@
|
||||
@page "/"
|
||||
@inject OverviewService OverviewService
|
||||
@using CutList.Core.Formatting
|
||||
|
||||
<PageTitle>CutList - Home</PageTitle>
|
||||
<PageTitle>CutList - Overview</PageTitle>
|
||||
|
||||
<section class="dashboard-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Production planning</p>
|
||||
<h1>Make every length count.</h1>
|
||||
<p class="hero-copy">Build a cut plan, optimize the stock, and send a clear list to the shop floor.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg"></i> New job</a>
|
||||
<a href="jobs" class="btn btn-quiet">View all jobs <i class="bi bi-arrow-up-right"></i></a>
|
||||
@if (loading)
|
||||
{
|
||||
<p><em>Loading overview…</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="overview-header">
|
||||
<div>
|
||||
<p class="eyebrow">Production planning</p>
|
||||
<h1>Cut planning at a glance.</h1>
|
||||
<p>See what is moving through the shop and the stock configurations that recur across jobs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-graphic" aria-hidden="true">
|
||||
<span class="cut-line line-one"></span><span class="cut-line line-two"></span><span class="cut-line line-three"></span>
|
||||
<b>01</b>
|
||||
</div>
|
||||
</section>
|
||||
<div class="overview-actions">
|
||||
<a href="jobs/new" class="btn btn-primary"><i class="bi bi-plus-lg" aria-hidden="true"></i> New job</a>
|
||||
<a href="jobs" class="btn btn-outline-primary">All jobs <i class="bi bi-arrow-right" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<div><p class="eyebrow">Start here</p><h2>Set up your workspace</h2></div>
|
||||
<span class="section-note">Three things to keep production moving</span>
|
||||
</div>
|
||||
<div class="action-grid">
|
||||
<a href="jobs" class="action-card action-card-featured">
|
||||
<span class="card-number">01</span><i class="bi bi-layers"></i><h3>Plan a job</h3>
|
||||
<p>Add required cuts, choose stock, then optimize the layout.</p><span class="card-link">Open jobs <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
<a href="materials" class="action-card">
|
||||
<span class="card-number">02</span><i class="bi bi-box-seam"></i><h3>Material library</h3>
|
||||
<p>Keep profiles, grades, and dimensions ready to reuse.</p><span class="card-link">Manage materials <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
<a href="tools" class="action-card">
|
||||
<span class="card-number">03</span><i class="bi bi-tools"></i><h3>Cutting tools</h3>
|
||||
<p>Set kerf values so the numbers match the real cut.</p><span class="card-link">Configure tools <i class="bi bi-arrow-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@if (loadError is not null)
|
||||
{
|
||||
<div class="alert alert-warning mt-4" role="alert">
|
||||
<strong>Overview data is unavailable.</strong> @loadError
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="workflow-panel">
|
||||
<div><p class="eyebrow">From estimate to shop floor</p><h2>A simple, reliable run of work.</h2></div>
|
||||
<ol class="workflow-list">
|
||||
<li><span>1</span><div><strong>Define material</strong><small>Profile, size, and grade</small></div></li>
|
||||
<li><span>2</span><div><strong>Add stock & parts</strong><small>Lengths, quantities, and kerf</small></div></li>
|
||||
<li><span>3</span><div><strong>Optimize & print</strong><small>Efficient cuts, ready to run</small></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
@if (!IsNew && job.IsLocked)
|
||||
{
|
||||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only">
|
||||
<div>
|
||||
<i class="bi bi-lock-fill me-2"></i>
|
||||
<strong>This job is locked</strong> — locked on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
|
||||
@@ -503,6 +503,7 @@ else
|
||||
private MultiMaterialPackingSummary? summary;
|
||||
private bool optimizing;
|
||||
private bool lockingJob;
|
||||
private bool showLengthsInInches = true;
|
||||
|
||||
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
|
||||
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
|
||||
@@ -511,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()
|
||||
{
|
||||
@@ -987,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)
|
||||
{
|
||||
@@ -1027,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>
|
||||
@@ -1042,23 +1060,21 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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-box-seam me-2"></i>Material List</h5>
|
||||
@if (job.IsLocked)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-warning btn-sm" @onclick="LockJob" disabled="@lockingJob">
|
||||
@if (lockingJob)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<i class="bi bi-lock me-1"></i>Lock Job
|
||||
</button>
|
||||
<span class="badge bg-success print-screen-only"><i class="bi bi-lock-fill me-1"></i>Job Locked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -1084,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>
|
||||
}
|
||||
@@ -1158,16 +1174,21 @@ else
|
||||
{
|
||||
<tr>
|
||||
<td>@binNum</td>
|
||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
|
||||
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.Length)</td>
|
||||
<td>
|
||||
@foreach (var item in entry.Bin.Items)
|
||||
{
|
||||
<span class="badge me-1 mb-1 fs-6" style="background-color: #6c8ebf; font-weight: 500;">
|
||||
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
|
||||
<span class="badge me-1 mb-1 fs-6 cut-part-badge">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Name))
|
||||
{
|
||||
<span class="cut-part-badge-name">@item.Name</span>
|
||||
<span class="cut-part-badge-divider" aria-hidden="true"></span>
|
||||
}
|
||||
<span class="cut-part-badge-length">@FormatResultLength(item.Length)</span>
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
|
||||
<td style="white-space: nowrap;">@FormatResultLength(entry.Bin.RemainingLength)</td>
|
||||
</tr>
|
||||
binNum++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using CutList.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CutList.Web.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260802212500_RemoveJobNumberPadding")]
|
||||
public partial class RemoveJobNumberPadding : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Jobs
|
||||
SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))
|
||||
WHERE JobNumber LIKE 'JOB-%';
|
||||
");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE Jobs
|
||||
SET JobNumber = 'JOB-' + RIGHT('00000' + CAST(Id AS varchar(5)), 5)
|
||||
WHERE JobNumber LIKE 'JOB-%';
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
|
||||
builder.Services.AddScoped<MaterialService>();
|
||||
builder.Services.AddScoped<StockItemService>();
|
||||
builder.Services.AddScoped<JobService>();
|
||||
builder.Services.AddScoped<OverviewService>();
|
||||
builder.Services.AddScoped<CutListPackingService>();
|
||||
builder.Services.AddScoped<ReportService>();
|
||||
builder.Services.AddScoped<CatalogService>();
|
||||
|
||||
@@ -42,35 +42,17 @@ public class JobService
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
job ??= new Job();
|
||||
job.JobNumber = await GenerateJobNumberAsync(context);
|
||||
job.JobNumber = CreateTemporaryJobNumber();
|
||||
job.CreatedAt = DateTime.UtcNow;
|
||||
context.Jobs.Add(job);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
job.JobNumber = $"JOB-{job.Id}";
|
||||
await context.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateJobNumberAsync()
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
return await GenerateJobNumberAsync(context);
|
||||
}
|
||||
|
||||
private static async Task<string> GenerateJobNumberAsync(ApplicationDbContext context)
|
||||
{
|
||||
var maxNumber = await context.Jobs
|
||||
.Where(j => j.JobNumber.StartsWith("JOB-"))
|
||||
.Select(j => j.JobNumber)
|
||||
.MaxAsync() as string;
|
||||
|
||||
if (maxNumber == null)
|
||||
return "JOB-00001";
|
||||
|
||||
var numPart = maxNumber.Substring(4);
|
||||
if (int.TryParse(numPart, out var num))
|
||||
return $"JOB-{num + 1:D5}";
|
||||
|
||||
return $"JOB-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
}
|
||||
private static string CreateTemporaryJobNumber() => $"TMP-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
public async Task<Job> QuickCreateAsync(string? customer = null)
|
||||
{
|
||||
@@ -142,7 +124,7 @@ public class JobService
|
||||
|
||||
var duplicate = new Job
|
||||
{
|
||||
JobNumber = await GenerateJobNumberAsync(context),
|
||||
JobNumber = CreateTemporaryJobNumber(),
|
||||
Name = string.IsNullOrWhiteSpace(original.Name) ? null : $"{original.Name} (Copy)",
|
||||
Customer = original.Customer,
|
||||
CuttingToolId = original.CuttingToolId,
|
||||
@@ -153,6 +135,9 @@ public class JobService
|
||||
context.Jobs.Add(duplicate);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
duplicate.JobNumber = $"JOB-{duplicate.Id}";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Copy parts
|
||||
foreach (var part in original.Parts)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using CutList.Web.Data;
|
||||
using CutList.Web.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CutList.Web.Services;
|
||||
|
||||
public class OverviewService
|
||||
{
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _factory;
|
||||
|
||||
public OverviewService(IDbContextFactory<ApplicationDbContext> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<OverviewSnapshot> GetSnapshotAsync()
|
||||
{
|
||||
await using var context = _factory.CreateDbContext();
|
||||
|
||||
var recentJobs = await context.Jobs
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(j => new RecentJobOverview(
|
||||
j.Id,
|
||||
j.JobNumber,
|
||||
j.Name,
|
||||
j.Customer,
|
||||
j.CreatedAt,
|
||||
j.LockedAt != null,
|
||||
j.Parts.Sum(p => (int?)p.Quantity) ?? 0))
|
||||
.ToListAsync();
|
||||
|
||||
var frequentStock = await context.JobStocks
|
||||
.AsNoTracking()
|
||||
.GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.MaterialId,
|
||||
g.Key.LengthInches,
|
||||
g.Key.Shape,
|
||||
g.Key.Size,
|
||||
JobCount = g.Select(s => s.JobId).Distinct().Count()
|
||||
})
|
||||
.OrderByDescending(s => s.JobCount)
|
||||
.ThenBy(s => s.Shape)
|
||||
.ThenBy(s => s.Size)
|
||||
.ThenBy(s => s.LengthInches)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
var totalJobs = await context.Jobs.CountAsync();
|
||||
var openJobs = await context.Jobs.CountAsync(j => j.LockedAt == null);
|
||||
var totalParts = await context.JobParts.SumAsync(p => (int?)p.Quantity) ?? 0;
|
||||
var activeStockItems = await context.StockItems.CountAsync(s => s.IsActive);
|
||||
|
||||
return new OverviewSnapshot(
|
||||
totalJobs,
|
||||
openJobs,
|
||||
totalParts,
|
||||
activeStockItems,
|
||||
recentJobs,
|
||||
frequentStock.Select(s => new FrequentStockOverview(
|
||||
s.MaterialId,
|
||||
$"{s.Shape.GetDisplayName()} - {s.Size}",
|
||||
s.LengthInches,
|
||||
s.JobCount)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public record OverviewSnapshot(
|
||||
int TotalJobs,
|
||||
int OpenJobs,
|
||||
int TotalParts,
|
||||
int ActiveStockItems,
|
||||
IReadOnlyList<RecentJobOverview> RecentJobs,
|
||||
IReadOnlyList<FrequentStockOverview> FrequentStock);
|
||||
|
||||
public record RecentJobOverview(
|
||||
int Id,
|
||||
string JobNumber,
|
||||
string? Name,
|
||||
string? Customer,
|
||||
DateTime CreatedAt,
|
||||
bool IsLocked,
|
||||
int PartCount);
|
||||
|
||||
public record FrequentStockOverview(
|
||||
int MaterialId,
|
||||
string MaterialName,
|
||||
decimal LengthInches,
|
||||
int JobCount);
|
||||
@@ -239,19 +239,16 @@ h1 {
|
||||
--rail: #18242e;
|
||||
}
|
||||
|
||||
html, body { background: var(--paper); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
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; }
|
||||
.workspace-header { align-items: center; background: rgba(243,246,248,.9); border-bottom: 1px solid var(--line); display: flex; height: 4.5rem; justify-content: space-between; padding: 0 clamp(1.5rem, 4vw, 4.5rem); position: sticky; top: 0; z-index: 5; }
|
||||
.workspace-eyebrow, .eyebrow { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .14em; margin: 0; text-transform: uppercase; }
|
||||
.workspace-status { align-items: center; color: var(--muted); display: flex; font-size: .78rem; font-weight: 650; gap: .45rem; }
|
||||
.status-dot { background: #3d9b78; border-radius: 50%; box-shadow: 0 0 0 3px rgba(61,155,120,.14); height: .45rem; width: .45rem; }
|
||||
h1, h2, h3, h4, h5, h6 { color: var(--ink); font-family: Georgia, "Times New Roman", serif; font-weight: 700; letter-spacing: -.045em; }
|
||||
h1 { font-size: clamp(2.25rem, 4vw, 4.1rem); line-height: .98; }
|
||||
h2 { font-size: clamp(1.6rem, 2.5vw, 2.25rem); }
|
||||
.job-title { font-size: clamp(1.75rem, 2.5vw, 2.5rem); line-height: 1.1; }
|
||||
.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; }
|
||||
@@ -272,6 +269,26 @@ a, .btn-link { color: var(--accent-dark); }
|
||||
.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; }
|
||||
@@ -298,5 +315,5 @@ a, .btn-link { color: var(--accent-dark); }
|
||||
.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) { .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; }.workspace-header { height: 3.5rem; padding-left: 4.6rem; }.workspace-context { display: none; } .workspace-status { margin-left: auto; } }
|
||||
@media (max-width: 800px) { .overview-header { align-items: start; flex-direction: column; }.overview-grid { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 7rem minmax(0, 1fr) 4rem 1rem; }.overview-job-meta { grid-column: 2 / 4; }.overview-job-row time { grid-column: 3; grid-row: 1; } .dashboard-hero, .workflow-panel { grid-template-columns: 1fr; }.hero-graphic { display: none; }.action-grid { grid-template-columns: 1fr; }.section-heading { align-items: start; flex-direction: column; gap: .5rem; } }
|
||||
@media (max-width: 640.98px) { .content { padding-top: 2rem; }.overview-stats { grid-template-columns: 1fr; }.overview-job-row { grid-template-columns: 1fr auto; padding: 1rem; }.overview-job-id { grid-column: 1; }.overview-job-name { grid-column: 1; }.overview-job-meta { grid-column: 1; }.overview-job-row time { grid-column: 2; grid-row: 1; }.overview-row-arrow, .overview-lock { grid-column: 2; grid-row: 2 / span 2; align-self: center; }.overview-panel-heading { padding: 1.25rem 1rem 1rem; }.overview-panel-note { margin-left: 1rem; margin-right: 1rem; }.overview-stock-list li { padding-left: 1rem; padding-right: 1rem; } }
|
||||
|
||||
@@ -141,14 +141,27 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The selected tool belongs on the paper cut report, not in the interactive results layout. */
|
||||
.print-cut-method {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Print styles - Compact layout to save paper */
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: economy;
|
||||
print-color-adjust: economy;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.print-screen-only,
|
||||
.sidebar,
|
||||
.top-row,
|
||||
.page > main > .top-row,
|
||||
@@ -304,6 +317,14 @@
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -329,6 +350,15 @@
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
/* Badges lose their blue fills in print, so force their white screen text and divider to black. */
|
||||
.cut-part-badge {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.cut-part-badge-divider {
|
||||
border-left: 1px solid #000 !important;
|
||||
}
|
||||
|
||||
/* Cut list tables: hide screen header, show repeating print header in thead */
|
||||
.cutlist-material-screen-header {
|
||||
display: none !important;
|
||||
@@ -365,6 +395,12 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep each stock-bar result, its cuts, and its waste on one printed page. */
|
||||
.cutlist-material-card tbody tr {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Reduce spacing */
|
||||
.mb-4 {
|
||||
margin-bottom: 0.5rem !important;
|
||||
|
||||
+14
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Mcp", "CutList.Mcp\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Web", "CutList.Web\CutList.Web.csproj", "{E3B33DE6-803C-4557-BF40-D8A5DB154144}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CutList.Core.Tests", "CutList.Core.Tests\CutList.Core.Tests.csproj", "{95E386F9-C906-423D-9869-BB1D2EA755E5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -69,6 +71,18 @@ Global
|
||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E3B33DE6-803C-4557-BF40-D8A5DB154144}.Release|x86.Build.0 = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{95E386F9-C906-423D-9869-BB1D2EA755E5}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Focused regression checks for split part-number and length cut badges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class CutBadgeFormatTests(unittest.TestCase):
|
||||
def test_named_cut_badge_renders_part_number_and_length_in_separate_sections(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
cuts_cell_start = markup.index("@foreach (var item in entry.Bin.Items)", results_start)
|
||||
cut_rows_start = markup.rfind("<td>", results_start, cuts_cell_start)
|
||||
cut_rows = markup[cut_rows_start:markup.index("</td>", cuts_cell_start)]
|
||||
|
||||
self.assertIn('class="badge me-1 mb-1 fs-6 cut-part-badge"', cut_rows)
|
||||
self.assertIn('class="cut-part-badge-name"', cut_rows)
|
||||
self.assertIn('class="cut-part-badge-length"', cut_rows)
|
||||
self.assertIn('@item.Name', cut_rows)
|
||||
self.assertIn('@FormatResultLength(item.Length)', cut_rows)
|
||||
self.assertNotIn('$"{item.Name} (', cut_rows)
|
||||
|
||||
def test_length_section_has_a_darker_background_than_the_part_number_section(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
|
||||
self.assertIn('.cut-part-badge {', css)
|
||||
self.assertIn('.cut-part-badge-name {', css)
|
||||
self.assertIn('.cut-part-badge-length {', css)
|
||||
self.assertIn('background-color: #4c6680;', css)
|
||||
|
||||
def test_printed_cut_badges_keep_part_numbers_and_separators_legible(self) -> None:
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
print_styles = report_css[report_css.index("@media print {"):]
|
||||
|
||||
self.assertIn(".cut-part-badge {", print_styles)
|
||||
self.assertIn("color: #000 !important;", print_styles)
|
||||
self.assertIn(".cut-part-badge-divider {", print_styles)
|
||||
self.assertIn("border-left: 1px solid #000 !important;", print_styles)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Regression checks for compact CutList identifiers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_SERVICE = REPO_ROOT / "CutList.Web/Services/JobService.cs"
|
||||
UNPAD_MIGRATION = REPO_ROOT / "CutList.Web/Migrations/20260802212500_RemoveJobNumberPadding.cs"
|
||||
|
||||
|
||||
class JobNumberFormatTests(unittest.TestCase):
|
||||
def test_new_jobs_use_the_database_id_without_zero_padding(self) -> None:
|
||||
service = JOB_SERVICE.read_text()
|
||||
|
||||
self.assertIn('job.JobNumber = $"JOB-{job.Id}";', service)
|
||||
self.assertNotIn("D5", service)
|
||||
self.assertNotIn("MaxAsync() as string", service)
|
||||
|
||||
def test_existing_job_numbers_are_converted_to_their_database_ids(self) -> None:
|
||||
migration = UNPAD_MIGRATION.read_text()
|
||||
|
||||
self.assertIn("SET JobNumber = 'JOB-' + CAST(Id AS varchar(11))", migration)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Focused regression checks for job-level actions on the Results tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JOB_EDITOR = REPO_ROOT / "CutList.Web/Components/Pages/Jobs/Edit.razor"
|
||||
|
||||
|
||||
class JobResultsActionsTests(unittest.TestCase):
|
||||
def test_lock_job_is_in_the_results_action_row_before_result_cards(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
result_cards_start = markup.index("@if (packResult != null && summary != null)", results_start)
|
||||
action_row = markup[results_start:result_cards_start]
|
||||
|
||||
self.assertIn('@onclick="PrintReport"', action_row)
|
||||
self.assertIn('@onclick="LockJob"', action_row)
|
||||
self.assertIn("Lock Job", action_row)
|
||||
|
||||
def test_print_report_hides_job_lock_status(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
|
||||
self.assertIn('class="alert alert-warning d-flex justify-content-between align-items-center mb-3 print-screen-only"', markup)
|
||||
self.assertIn('<span class="badge bg-success print-screen-only">', markup)
|
||||
self.assertIn(".print-screen-only", report_css)
|
||||
self.assertIn("display: none !important;", report_css)
|
||||
|
||||
def test_print_report_forces_backgrounds_to_plain_white(self) -> None:
|
||||
report_css = (REPO_ROOT / "CutList.Web/wwwroot/css/report.css").read_text()
|
||||
print_styles = report_css[report_css.index("@media print {"):]
|
||||
|
||||
self.assertIn("*::before", print_styles)
|
||||
self.assertIn("background: transparent !important;", print_styles)
|
||||
self.assertIn("box-shadow: none !important;", print_styles)
|
||||
|
||||
def test_print_report_shows_the_selected_cutting_method_and_kerf(self) -> None:
|
||||
markup = JOB_EDITOR.read_text()
|
||||
results_start = markup.index("private RenderFragment RenderResultsTab()")
|
||||
summary_start = markup.index('<div class="row mb-4 print-summary">', results_start)
|
||||
material_list_start = markup.index('<!-- Material List:', summary_start)
|
||||
report_summary = markup[summary_start:material_list_start]
|
||||
|
||||
self.assertIn('class="print-cut-method"', report_summary)
|
||||
self.assertIn("<strong>Cut Method:</strong>", report_summary)
|
||||
self.assertIn("@job.CuttingTool.Name", report_summary)
|
||||
self.assertIn("Kerf:", report_summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ 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:
|
||||
@@ -58,6 +59,16 @@ class NavMenuBrandContrastTests(unittest.TestCase):
|
||||
css = NAV_MENU_CSS.read_text()
|
||||
self.assertIn(".top-row { justify-content: flex-start;", css)
|
||||
|
||||
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
|
||||
shell = APP_SHELL.read_text()
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn("family=IBM+Plex+Sans", shell)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', css)
|
||||
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
|
||||
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
|
||||
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Focused regression checks for the data-backed CutList overview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class OverviewDashboardTests(unittest.TestCase):
|
||||
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn("GetSnapshotAsync", source)
|
||||
self.assertIn("context.Jobs", source)
|
||||
self.assertIn("context.JobStocks", source)
|
||||
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
|
||||
|
||||
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||
self.assertIn("Recently created jobs", markup)
|
||||
self.assertIn("Frequently specified stock", markup)
|
||||
self.assertIn("Based on stock configured on jobs", markup)
|
||||
self.assertIn("overview.RecentJobs", markup)
|
||||
self.assertIn("overview.FrequentStock", markup)
|
||||
|
||||
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn(
|
||||
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
|
||||
source,
|
||||
)
|
||||
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
|
||||
|
||||
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn(".overview-grid", css)
|
||||
self.assertIn(".overview-stock-list", css)
|
||||
self.assertIn(".overview-recent-jobs", css)
|
||||
|
||||
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("loadError", markup)
|
||||
self.assertIn("Overview data is unavailable", markup)
|
||||
self.assertIn("catch (Exception ex)", markup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user