From f3b911f37ceb2d5cb5a49bd86a5efde4145d6062 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 2 Aug 2026 20:18:07 +0000 Subject: [PATCH] feat(web): add data-backed planning overview --- CLAUDE.md | 2 + CutList.Web/Components/Pages/Home.razor | 185 ++++++++++++++++++------ CutList.Web/Program.cs | 1 + CutList.Web/Services/OverviewService.cs | 91 ++++++++++++ CutList.Web/wwwroot/css/app.css | 18 ++- tests/test_overview_dashboard.py | 49 +++++++ 6 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 CutList.Web/Services/OverviewService.cs create mode 100644 tests/test_overview_dashboard.py diff --git a/CLAUDE.md b/CLAUDE.md index 86c4528..e991af2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CutList.Web/Components/Pages/Home.razor b/CutList.Web/Components/Pages/Home.razor index ca38ad0..7db2033 100644 --- a/CutList.Web/Components/Pages/Home.razor +++ b/CutList.Web/Components/Pages/Home.razor @@ -1,49 +1,148 @@ @page "/" +@inject OverviewService OverviewService +@using CutList.Core.Formatting -CutList - Home +CutList - Overview -
-
-

Production planning

-

Make every length count.

-

Build a cut plan, optimize the stock, and send a clear list to the shop floor.

-
- New job - View all jobs +@if (loading) +{ +

Loading overview…

+} +else +{ +
+
+

Production planning

+

Cut planning at a glance.

+

See what is moving through the shop and the stock configurations that recur across jobs.

-
- -
+
+ New job + All jobs +
+ -
-
-

Start here

Set up your workspace

- Three things to keep production moving -
- -
+ @if (loadError is not null) + { + + } -
-

From estimate to shop floor

A simple, reliable run of work.

-
    -
  1. 1
    Define materialProfile, size, and grade
  2. -
  3. 2
    Add stock & partsLengths, quantities, and kerf
  4. -
  5. 3
    Optimize & printEfficient cuts, ready to run
  6. -
-
+
+ + Jobs + @overview.TotalJobs + @overview.OpenJobs open for planning + + + Parts required + @overview.TotalParts + Across all current jobs + + + Stock options + @overview.ActiveStockItems + Active catalog lengths + +
+ +
+
+
+
+

Work queue

+

Recently created jobs

+
+ View all +
+ @if (overview.RecentJobs.Count == 0) + { +
+ +

No jobs yet. Create a job to begin building your planning history.

+ Create first job +
+ } + else + { + + } +
+ +
+
+
+

Planning signal

+

Frequently specified stock

+
+ Manage stock +
+

Based on stock configured on jobs. This shows recurring planning demand, not on-hand inventory.

+ @if (overview.FrequentStock.Count == 0) + { +
+ +

Add stock to jobs to reveal the configurations used most often.

+
+ } + else + { +
    + @for (var stockIndex = 0; stockIndex < overview.FrequentStock.Count; stockIndex++) + { + var stock = overview.FrequentStock[stockIndex]; +
  1. + @((stockIndex + 1).ToString("D2")) +
    + @stock.MaterialName + @ArchUnits.FormatFromInches((double)stock.LengthInches) stock length +
    + @stock.JobCount @(stock.JobCount == 1 ? "job" : "jobs") +
  2. + } +
+ } +
+
+} + +@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; + } + } +} diff --git a/CutList.Web/Program.cs b/CutList.Web/Program.cs index 96d06b9..0197c70 100644 --- a/CutList.Web/Program.cs +++ b/CutList.Web/Program.cs @@ -27,6 +27,7 @@ builder.Services.AddDbContextFactory(options => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/CutList.Web/Services/OverviewService.cs b/CutList.Web/Services/OverviewService.cs new file mode 100644 index 0000000..e7e00d6 --- /dev/null +++ b/CutList.Web/Services/OverviewService.cs @@ -0,0 +1,91 @@ +using CutList.Web.Data; +using CutList.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CutList.Web.Services; + +public class OverviewService +{ + private readonly IDbContextFactory _factory; + + public OverviewService(IDbContextFactory factory) + { + _factory = factory; + } + + public async Task 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 }) + .Select(g => new + { + g.Key.MaterialId, + g.Key.LengthInches, + JobCount = g.Select(s => s.JobId).Distinct().Count(), + Material = g.Select(s => s.Material).First() + }) + .OrderByDescending(s => s.JobCount) + .ThenBy(s => s.Material.Shape) + .ThenBy(s => s.Material.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.Material.DisplayName, + s.LengthInches, + s.JobCount)).ToList()); + } +} + +public record OverviewSnapshot( + int TotalJobs, + int OpenJobs, + int TotalParts, + int ActiveStockItems, + IReadOnlyList RecentJobs, + IReadOnlyList 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); diff --git a/CutList.Web/wwwroot/css/app.css b/CutList.Web/wwwroot/css/app.css index 820890d..a7e4c70 100644 --- a/CutList.Web/wwwroot/css/app.css +++ b/CutList.Web/wwwroot/css/app.css @@ -272,6 +272,20 @@ 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; } +/* 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: Georgia, "Times New Roman", serif; font-size: 2.25rem; letter-spacing: -.06em; 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.5rem; 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 +312,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; }.workspace-header { height: 3.5rem; padding-left: 4.6rem; }.workspace-context { display: none; } .workspace-status { margin-left: auto; } } diff --git a/tests/test_overview_dashboard.py b/tests/test_overview_dashboard.py new file mode 100644 index 0000000..b1fac6e --- /dev/null +++ b/tests/test_overview_dashboard.py @@ -0,0 +1,49 @@ +"""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 })", 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_service_is_registered_and_has_dashboard_layout_rules(self) -> None: + self.assertIn("AddScoped()", 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()