Compare commits

..
13 Commits
Author SHA1 Message Date
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
21 changed files with 670 additions and 107 deletions
+7 -4
View File
@@ -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);
}
}
+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)}\"";
}
}
}
+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,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; }
+142 -43
View File
@@ -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 &amp; parts</strong><small>Lengths, quantities, and kerf</small></div></li>
<li><span>3</span><div><strong>Optimize &amp; 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;
}
}
}
+39 -18
View File
@@ -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-%';
");
}
}
}
+1
View File
@@ -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>();
+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);
+28 -11
View File
@@ -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; } }
+38 -2
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,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
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
+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()
+11
View File
@@ -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()
+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()