-
Production planning
-
Make every length count.
-
Build a cut plan, optimize the stock, and send a clear list to the shop floor.
-
-
-
- 01
-
-
+
+
-
-
-
Start here
Set up your workspace
-
Three things to keep production moving
-
-
-
+ @if (loadError is not null)
+ {
+
+ Overview data is unavailable. @loadError
+
+ }
-
- From estimate to shop floor
A simple, reliable run of work.
-
- - 1
Define materialProfile, size, and grade
- - 2
Add stock & partsLengths, quantities, and kerf
- - 3
Optimize & printEfficient cuts, ready to run
-
-
+
+
+
+
+
+
+
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];
+ -
+ @((stockIndex + 1).ToString("D2"))
+
+ @stock.MaterialName
+ @ArchUnits.FormatFromInches((double)stock.LengthInches) stock length
+
+ @stock.JobCount @(stock.JobCount == 1 ? "job" : "jobs")
+
+ }
+
+ }
+
+
+}
+
+@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()