Compare commits
15
Commits
4f21d536f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1564451d | ||
|
|
5409479e00 | ||
|
|
25af9e3ba8 | ||
|
|
3ccaddef0c | ||
|
|
79bf56afd8 | ||
|
|
96f5824d12 | ||
|
|
db054aa6dc | ||
|
|
8e2027d81b | ||
|
|
57c7aa5f9f | ||
|
|
34c9b77501 | ||
|
|
203ea0c69d | ||
|
|
5cbd06711c | ||
|
|
f3b911f37c | ||
|
|
1f04c5343a | ||
|
|
3fbcb05d4d |
@@ -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>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
.top-row { padding: 1.5rem 1.35rem 1.25rem; }
|
||||
.top-row { justify-content: flex-start; padding: 1.5rem 1.35rem 1.25rem; }
|
||||
.brand-lockup { display: flex; align-items: center; gap: .75rem; }
|
||||
.brand-mark { align-items: center; background: #f26b38; border-radius: .45rem; color: #161817; display: flex; font-size: 1rem; height: 2.25rem; justify-content: center; text-decoration: none; transform: rotate(-8deg); width: 2.25rem; }
|
||||
.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: #f7f3eb; 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 .brand-cut { color: #f26b38; }
|
||||
.navbar-brand .brand-list { color: #fffaf0; }
|
||||
.navbar-brand small { color: #8f968d; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
|
||||
.nav-section-label { color: #737a73; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
|
||||
.navbar-brand { color: #17212b; font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.25rem; font-weight: 700; letter-spacing: -.035em; line-height: .95; text-decoration: none; }
|
||||
.navbar-brand .brand-cut { color: #2b75a8; }
|
||||
.navbar-brand .brand-list { color: #17212b; }
|
||||
.navbar-brand small { color: #60778b; display: block; font-family: Arial, sans-serif; font-size: .48rem; font-weight: 700; letter-spacing: .14em; margin-top: .45rem; }
|
||||
.nav-section-label { color: #91a8b8; font-size: .62rem; font-weight: 700; letter-spacing: .13em; margin: 1.35rem 1.5rem .5rem; text-transform: uppercase; }
|
||||
.nav-item { padding: .1rem .75rem; }
|
||||
.nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #bbc0b8; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
|
||||
.nav-item ::deep .nav-link i { color: #838b82; font-size: 1rem; width: 1.1rem; }
|
||||
.nav-item ::deep .nav-link { align-items: center; border-radius: .4rem; color: #c4d3dc; display: flex; font-size: .9rem; font-weight: 600; gap: .8rem; min-height: 2.8rem; padding: .65rem .75rem; transition: background .18s ease, color .18s ease; }
|
||||
.nav-item ::deep .nav-link i { color: #8fa7b7; font-size: 1rem; width: 1.1rem; }
|
||||
.nav-item ::deep .nav-link:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||
.nav-item ::deep a.active { background: #303531; color: #fff; }
|
||||
.nav-item ::deep a.active i { color: #f26b38; }
|
||||
.navbar-toggler { appearance: none; background: transparent; border: 1px solid #485048; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
|
||||
.nav-item ::deep a.active { background: #294655; color: #fff; }
|
||||
.nav-item ::deep a.active i { color: #72b5df; }
|
||||
.navbar-toggler { appearance: none; background: transparent; border: 1px solid #587382; border-radius: .35rem; color: white; cursor: pointer; height: 2.25rem; position: absolute; right: 1rem; top: 1rem; width: 2.75rem; }
|
||||
.navbar-toggler::after { content: '☰'; }
|
||||
.nav-scrollable { display: none; }
|
||||
.navbar-toggler:checked ~ .nav-scrollable { display: block; }
|
||||
|
||||
@@ -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);
|
||||
@@ -88,8 +88,8 @@ main {
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f3f1ee;
|
||||
border-bottom: 1px solid #e0ddd8;
|
||||
background-color: #edf2f5;
|
||||
border-bottom: 1px solid #d4dee5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
@@ -145,7 +145,7 @@ main {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
@@ -226,77 +226,94 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
/* CutList visual system */
|
||||
/* CutList visual system: industrial blue and steel, not seasonal orange/green. */
|
||||
:root {
|
||||
--ink: #20231f;
|
||||
--muted: #697168;
|
||||
--paper: #f4f1e9;
|
||||
--paper-bright: #fbfaf6;
|
||||
--line: #d9d5ca;
|
||||
--orange: #ed6b3b;
|
||||
--orange-dark: #d95a2d;
|
||||
--green: #28534a;
|
||||
--rail: #1d211e;
|
||||
--ink: #17212b;
|
||||
--muted: #617282;
|
||||
--paper: #f3f6f8;
|
||||
--paper-bright: #ffffff;
|
||||
--line: #d4dee5;
|
||||
--accent: #2b75a8;
|
||||
--accent-dark: #1d5d8d;
|
||||
--navy: #203747;
|
||||
--rail: #18242e;
|
||||
}
|
||||
|
||||
html, body { background: var(--paper); color: var(--ink); font-family: 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 #30342f; }
|
||||
.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(244,241,233,.88); 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: #4d9d75; border-radius: 50%; box-shadow: 0 0 0 3px rgba(77,157,117,.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; }
|
||||
a, .btn-link { color: var(--green); }
|
||||
.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(--orange); border-color: var(--orange); color: #231c19; }
|
||||
.btn-primary:hover, .btn-primary:focus { background: var(--orange-dark); border-color: var(--orange-dark); color: #fff; }
|
||||
.btn-outline-primary { border-color: #8e968c; color: var(--green); }
|
||||
.btn-outline-primary:hover { background: var(--green); border-color: var(--green); }
|
||||
.btn-success { background: var(--green); border-color: var(--green); }
|
||||
.btn-success:hover { background: #1f423a; border-color: #1f423a; }
|
||||
.form-control, .form-select { background: var(--paper-bright); border-color: #c9c5b9; border-radius: .35rem; padding: .62rem .75rem; }
|
||||
.form-control:focus, .form-select:focus { border-color: var(--orange); box-shadow: 0 0 0 .2rem rgba(237,107,59,.14); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover, .btn-primary:focus { background: var(--accent-dark); border-color: var(--accent-dark); color: #fff; }
|
||||
.btn-outline-primary { border-color: #8299a8; color: var(--accent-dark); }
|
||||
.btn-outline-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.btn-success { background: #267c6c; border-color: #267c6c; }
|
||||
.btn-success:hover { background: #1d6558; border-color: #1d6558; }
|
||||
.form-control, .form-select { background: var(--paper-bright); border-color: #c5d2db; border-radius: .35rem; padding: .62rem .75rem; }
|
||||
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 .2rem rgba(43,117,168,.14); }
|
||||
.card { background: var(--paper-bright); border: 1px solid var(--line); border-radius: .5rem; box-shadow: none; }
|
||||
.card-header { background: #eeebe3; }
|
||||
.table { --bs-table-striped-bg: rgba(32,35,31,.025); border-color: var(--line); font-size: .9rem; }
|
||||
.card-header { background: #edf2f5; }
|
||||
.table { --bs-table-striped-bg: rgba(23,33,43,.025); border-color: var(--line); font-size: .9rem; }
|
||||
.table > :not(caption) > * > * { padding: .85rem .75rem; }
|
||||
.table thead th { border-bottom-width: 1px; color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.table a { font-weight: 700; text-decoration-color: rgba(40,83,74,.28); text-underline-offset: .15em; }
|
||||
.table a { font-weight: 700; text-decoration-color: rgba(43,117,168,.28); text-underline-offset: .15em; }
|
||||
.table-actions { align-items: center; display: inline-flex; flex-wrap: nowrap; gap: .35rem; white-space: nowrap; }
|
||||
.table-actions .btn { align-items: center; display: inline-flex; justify-content: center; min-height: 2rem; min-width: 2rem; padding: .35rem .45rem; }
|
||||
.alert { border-radius: .4rem; border-width: 1px; }
|
||||
|
||||
/* Results cut badges keep the part number and its length visually distinct. */
|
||||
.cut-part-badge { background-color: #6c8ebf; display: inline-flex; font-weight: 500; overflow: hidden; padding: 0; }
|
||||
.cut-part-badge-name { padding: .35em .55em; }
|
||||
.cut-part-badge-divider { border-left: 1px solid rgba(255, 255, 255, .75); margin: .2em 0; }
|
||||
.cut-part-badge-length { background-color: #4c6680; padding: .35em .55em; }
|
||||
|
||||
/* Overview */
|
||||
.overview-header { align-items: end; border-bottom: 1px solid var(--line); display: flex; gap: 2rem; justify-content: space-between; padding-bottom: 2rem; }
|
||||
.overview-header h1 { margin: .4rem 0 .75rem; max-width: 14ch; }
|
||||
.overview-header > div > p:last-child { color: var(--muted); line-height: 1.55; margin: 0; max-width: 39rem; }
|
||||
.overview-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: .65rem; }
|
||||
.overview-stats { display: grid; gap: 1px; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 2rem 0; background: var(--line); border: 1px solid var(--line); }
|
||||
.overview-stat { background: var(--paper-bright); color: var(--ink); display: grid; gap: .25rem; padding: 1.4rem 1.5rem; text-decoration: none; }
|
||||
.overview-stat:hover { background: #edf4f8; color: var(--ink); }.overview-stat-label { color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }.overview-stat strong { font-family: "IBM Plex Sans", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 2rem; letter-spacing: -.04em; line-height: 1; }.overview-stat small { color: var(--muted); font-size: .78rem; }
|
||||
.overview-grid { align-items: start; display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); }
|
||||
.overview-recent-jobs { min-width: 0; }
|
||||
.overview-panel { background: var(--paper-bright); border: 1px solid var(--line); }.overview-panel-heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; padding: 1.5rem 1.5rem 1.2rem; }.overview-panel-heading h2 { font-size: 1.25rem; margin: .3rem 0 0; }.overview-panel-heading > a { font-size: .78rem; font-weight: 750; text-decoration: none; white-space: nowrap; }
|
||||
.overview-job-list { border-top: 1px solid var(--line); }.overview-job-row { align-items: center; border-bottom: 1px solid var(--line); color: var(--ink); display: grid; gap: .3rem 1rem; grid-template-columns: 7.5rem minmax(9rem, 1fr) minmax(8rem, .85fr) 3.75rem 1rem; padding: 1rem 1.5rem; text-decoration: none; }.overview-job-row:last-child { border-bottom: 0; }.overview-job-row:hover { background: #f2f6f8; color: var(--ink); }.overview-job-id { color: var(--accent-dark); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }.overview-job-name { font-weight: 750; }.overview-job-meta, .overview-job-row time { color: var(--muted); font-size: .78rem; }.overview-job-row time { text-align: right; }.overview-row-arrow, .overview-lock { color: var(--muted); font-size: .85rem; text-align: right; }.overview-lock { color: #9c6a20; }
|
||||
.overview-panel-note { color: var(--muted); font-size: .8rem; line-height: 1.5; margin: -.25rem 1.5rem 1rem; }.overview-stock-list { border-top: 1px solid var(--line); list-style: none; margin: 0; padding: 0; }.overview-stock-list li { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: .85rem; grid-template-columns: 1.7rem minmax(0, 1fr) auto; padding: 1rem 1.5rem; }.overview-stock-list li:last-child { border-bottom: 0; }.overview-stock-rank { color: #8796a1; font-size: .66rem; font-weight: 800; letter-spacing: .08em; }.overview-stock-list strong, .overview-stock-list span { display: block; }.overview-stock-list strong { font-size: .88rem; }.overview-stock-list div > span { color: var(--muted); font-size: .76rem; margin-top: .15rem; }.overview-stock-count { color: var(--accent-dark); font-size: 1.2rem; font-weight: 800; text-align: right; }.overview-stock-count small { color: var(--muted); display: block; font-size: .66rem; font-weight: 700; text-transform: uppercase; }.overview-empty { align-items: center; color: var(--muted); display: flex; flex-direction: column; gap: 1rem; justify-content: center; min-height: 265px; padding: 2rem; text-align: center; }.overview-empty .bi { color: #9baab4; font-size: 1.8rem; }.overview-empty p { line-height: 1.5; margin: 0; max-width: 20rem; }.overview-empty-compact { min-height: 190px; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-hero { background: var(--green); color: #f8f4ea; 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: #fffaf0; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||
.dashboard-hero .eyebrow { color: #b4c6b8; }
|
||||
.hero-copy { color: #d7e0d8; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
|
||||
.dashboard-hero { background: var(--navy); color: #f5f9fc; display: grid; gap: 2rem; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); min-height: 310px; overflow: hidden; padding: clamp(2rem, 5vw, 4.5rem); position: relative; }
|
||||
.dashboard-hero h1 { color: #f8fbfd; margin: .55rem 0 1rem; max-width: 8.5ch; }
|
||||
.dashboard-hero .eyebrow { color: #a8c5d7; }
|
||||
.hero-copy { color: #d1e0e9; font-size: 1.05rem; line-height: 1.6; margin: 0; max-width: 33rem; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 2rem; }
|
||||
.btn-quiet { align-items: center; color: #fffaf0; display: inline-flex; gap: .5rem; padding-left: .5rem; }
|
||||
.btn-quiet:hover { color: var(--orange); }
|
||||
.btn-quiet { align-items: center; color: #f8fbfd; display: inline-flex; gap: .5rem; padding-left: .5rem; }
|
||||
.btn-quiet:hover { color: #8fc5e4; }
|
||||
.hero-graphic { align-self: stretch; min-height: 180px; position: relative; }
|
||||
.hero-graphic b { bottom: -.35em; color: rgba(255,255,255,.09); font-family: Georgia, serif; font-size: clamp(8rem, 18vw, 15rem); line-height: .7; position: absolute; right: -.1em; }
|
||||
.cut-line { background: var(--orange); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
|
||||
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #b8c9ba; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
|
||||
.cut-line { background: var(--accent); display: block; height: 5px; position: absolute; transform: rotate(-28deg); transform-origin: left center; }
|
||||
.line-one { left: 6%; top: 23%; width: 75%; }.line-two { background: #9fc5db; left: 20%; top: 51%; width: 64%; }.line-three { left: 1%; top: 78%; width: 53%; }
|
||||
.dashboard-section { margin-top: 4rem; }
|
||||
.section-heading { align-items: end; display: flex; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
.section-heading h2 { margin: .35rem 0 0; }.section-note { color: var(--muted); font-size: .82rem; }
|
||||
.action-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.action-card { background: var(--paper-bright); border: 1px solid var(--line); color: var(--ink); display: flex; flex-direction: column; min-height: 255px; padding: 1.5rem; position: relative; text-decoration: none; transition: transform .2s ease, box-shadow .2s ease; }
|
||||
.action-card:hover { box-shadow: 5px 5px 0 var(--line); color: var(--ink); transform: translate(-2px, -2px); }
|
||||
.action-card > .bi { color: var(--orange); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
|
||||
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #9a9f96; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
|
||||
.action-card-featured { background: #e7e1d5; border-color: #c7bead; }.card-link { color: var(--green); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
|
||||
.workflow-panel { background: #e9e5da; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
|
||||
.action-card > .bi { color: var(--accent); font-size: 1.55rem; margin-top: 1.35rem; }.action-card h3 { font-size: 1.35rem; margin: 1rem 0 .4rem; }
|
||||
.action-card p { color: var(--muted); font-size: .9rem; line-height: 1.55; margin: 0; }.card-number { color: #8494a1; font-size: .7rem; font-weight: 800; letter-spacing: .12em; }
|
||||
.action-card-featured { background: #e8f0f5; border-color: #bfd0dc; }.card-link { color: var(--accent-dark); font-size: .8rem; font-weight: 800; margin-top: auto; padding-top: 1.25rem; }
|
||||
.workflow-panel { background: #e8f0f5; display: grid; gap: 2rem; grid-template-columns: .75fr 1.25fr; margin-top: 4rem; padding: 2.25rem; }
|
||||
.workflow-panel h2 { margin: .35rem 0 0; max-width: 14rem; }.workflow-list { counter-reset: item; display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||
.workflow-list li { align-items: center; border-bottom: 1px solid #ccc5b7; display: flex; gap: 1rem; padding: .65rem 0; }.workflow-list li:last-child { border-bottom: 0; }
|
||||
.workflow-list li > span { align-items: center; background: var(--orange); border-radius: 50%; display: inline-flex; font-size: .72rem; font-weight: 800; height: 1.6rem; justify-content: center; width: 1.6rem; }
|
||||
.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()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Regression checks for the CutList navigation brand on its light header."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NAV_MENU = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor"
|
||||
NAV_MENU_CSS = REPO_ROOT / "CutList.Web/Components/Layout/NavMenu.razor.css"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
APP_SHELL = REPO_ROOT / "CutList.Web/Components/App.razor"
|
||||
|
||||
|
||||
def selector_color(css: str, selector: str) -> str:
|
||||
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css)
|
||||
if match is None:
|
||||
raise AssertionError(f"Missing CSS selector: {selector}")
|
||||
|
||||
color = re.search(r"\bcolor:\s*(#[0-9a-fA-F]{6})", match.group(1))
|
||||
if color is None:
|
||||
raise AssertionError(f"Missing hex color for selector: {selector}")
|
||||
return color.group(1)
|
||||
|
||||
|
||||
def contrast_ratio(foreground: str, background: str) -> float:
|
||||
def luminance(color: str) -> float:
|
||||
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
|
||||
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels]
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
||||
|
||||
lighter, darker = sorted((luminance(foreground), luminance(background)), reverse=True)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
class NavMenuBrandContrastTests(unittest.TestCase):
|
||||
def test_list_word_has_accessible_contrast_against_light_top_row(self) -> None:
|
||||
list_color = selector_color(NAV_MENU_CSS.read_text(), ".navbar-brand .brand-list")
|
||||
top_row_color = selector_color(APP_CSS.read_text(), ".top-row")
|
||||
|
||||
self.assertGreaterEqual(contrast_ratio(list_color, top_row_color), 4.5)
|
||||
|
||||
def test_brand_keeps_cut_and_list_as_adjacent_words(self) -> None:
|
||||
markup = NAV_MENU.read_text()
|
||||
self.assertIn('<span class="brand-cut">Cut</span><span class="brand-list">List</span>', markup)
|
||||
|
||||
def test_visual_system_uses_the_industrial_blue_accent(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn("--accent: #2b75a8;", css)
|
||||
self.assertIn("--navy: #203747;", css)
|
||||
self.assertNotIn("--orange:", css)
|
||||
|
||||
def test_desktop_content_padding_does_not_push_the_nav_brand_right(self) -> None:
|
||||
css = APP_CSS.read_text()
|
||||
self.assertNotIn(".top-row, article {", css)
|
||||
|
||||
def test_nav_brand_is_left_aligned_with_the_navigation_items(self) -> None:
|
||||
css = NAV_MENU_CSS.read_text()
|
||||
self.assertIn(".top-row { justify-content: flex-start;", css)
|
||||
|
||||
def test_typography_uses_a_sans_serif_face_and_compact_heading_scale(self) -> None:
|
||||
shell = APP_SHELL.read_text()
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn("family=IBM+Plex+Sans", shell)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', css)
|
||||
self.assertIn("h1 { font-size: clamp(1.85rem, 3.2vw, 3.15rem);", css)
|
||||
self.assertIn("h2 { font-size: clamp(1.35rem, 2vw, 1.85rem);", css)
|
||||
self.assertIn(".overview-panel-heading h2 { font-size: 1.25rem;", css)
|
||||
self.assertIn('font-family: "IBM Plex Sans",', NAV_MENU_CSS.read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Focused regression checks for the data-backed CutList overview."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HOME_PAGE = REPO_ROOT / "CutList.Web/Components/Pages/Home.razor"
|
||||
OVERVIEW_SERVICE = REPO_ROOT / "CutList.Web/Services/OverviewService.cs"
|
||||
PROGRAM = REPO_ROOT / "CutList.Web/Program.cs"
|
||||
APP_CSS = REPO_ROOT / "CutList.Web/wwwroot/css/app.css"
|
||||
|
||||
|
||||
class OverviewDashboardTests(unittest.TestCase):
|
||||
def test_overview_uses_a_dedicated_data_service(self) -> None:
|
||||
self.assertTrue(OVERVIEW_SERVICE.exists(), "Expected a data-backed overview service")
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn("GetSnapshotAsync", source)
|
||||
self.assertIn("context.Jobs", source)
|
||||
self.assertIn("context.JobStocks", source)
|
||||
self.assertIn("OrderByDescending(j => j.CreatedAt)", source)
|
||||
self.assertIn("GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })", source)
|
||||
|
||||
def test_overview_renders_recent_jobs_and_frequently_specified_stock(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("@inject OverviewService OverviewService", markup)
|
||||
self.assertIn("Recently created jobs", markup)
|
||||
self.assertIn("Frequently specified stock", markup)
|
||||
self.assertIn("Based on stock configured on jobs", markup)
|
||||
self.assertIn("overview.RecentJobs", markup)
|
||||
self.assertIn("overview.FrequentStock", markup)
|
||||
|
||||
def test_overview_groups_stock_by_scalar_material_fields(self) -> None:
|
||||
source = OVERVIEW_SERVICE.read_text()
|
||||
self.assertIn(
|
||||
"GroupBy(s => new { s.MaterialId, s.LengthInches, s.Material.Shape, s.Material.Size })",
|
||||
source,
|
||||
)
|
||||
self.assertNotIn("Material = g.Select(s => s.Material).First()", source)
|
||||
|
||||
def test_overview_service_is_registered_and_has_dashboard_layout_rules(self) -> None:
|
||||
self.assertIn("AddScoped<OverviewService>()", PROGRAM.read_text())
|
||||
css = APP_CSS.read_text()
|
||||
self.assertIn(".overview-grid", css)
|
||||
self.assertIn(".overview-stock-list", css)
|
||||
self.assertIn(".overview-recent-jobs", css)
|
||||
|
||||
def test_overview_shows_an_explicit_unavailable_state_if_data_cannot_load(self) -> None:
|
||||
markup = HOME_PAGE.read_text()
|
||||
self.assertIn("loadError", markup)
|
||||
self.assertIn("Overview data is unavailable", markup)
|
||||
self.assertIn("catch (Exception ex)", markup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user