Compare commits

..

5 Commits

Author SHA1 Message Date
cubecraft-agents[bot]
d93c6cd919 CUB-71: Add mobile-first design system document 2026-05-01 12:58:15 -04:00
42285c5dac Merge pull request 'CUB-33: Integrate Moonraker filament usage polling' (#33) from agent/dex/CUB-33-moonraker-usage-polling-v2 into dev
Some checks failed
Dev Build / build-test (push) Failing after 2m26s
2026-04-29 17:18:05 -04:00
9cd619b5ee CUB-33: integrate Moonraker filament usage polling
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m21s
2026-04-29 11:50:18 -04:00
ddae95767f Merge pull request 'CUB-35: Build add/edit filament modal' (#20) from agent/rex/CUB-35-filament-add-edit-modal into dev
Some checks failed
Dev Build / build-test (push) Failing after 4m26s
2026-04-29 11:29:32 -04:00
15187cab65 CUB-35: build add/edit filament modal with Angular Material Dialog
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m28s
2026-04-29 11:16:15 -04:00
13 changed files with 1806 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
using Extrudex.Domain.Entities;
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Service for persisting and querying filament usage records.
/// Tracks consumption per print job and per spool for COGS and inventory tracking.
/// </summary>
public interface IFilamentUsageService
{
/// <summary>
/// Records a new filament usage entry for a print job.
/// </summary>
/// <param name="printJobId">The print job that consumed the filament.</param>
/// <param name="spoolId">The spool that provided the filament.</param>
/// <param name="printerId">The printer that executed the print.</param>
/// <param name="gramsUsed">Grams of filament consumed.</param>
/// <param name="mmExtruded">Millimeters of filament extruded.</param>
/// <param name="notes">Optional notes about this usage record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created FilamentUsage entity.</returns>
Task<FilamentUsage> RecordUsageAsync(
Guid printJobId,
Guid spoolId,
Guid printerId,
decimal gramsUsed,
decimal mmExtruded,
string? notes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific print job.
/// </summary>
/// <param name="printJobId">The print job ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the print job.</returns>
Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all filament usage records for a specific spool.
/// </summary>
/// <param name="spoolId">The spool ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of filament usage records for the spool.</returns>
Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,79 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// EF Corebacked implementation of the filament usage service.
/// Persists usage records to the database and provides query methods
/// for retrieving usage by print job or spool.
/// </summary>
public class FilamentUsageService : IFilamentUsageService
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILogger<FilamentUsageService> _logger;
public FilamentUsageService(
ExtrudexDbContext dbContext,
ILogger<FilamentUsageService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<FilamentUsage> RecordUsageAsync(
Guid printJobId,
Guid spoolId,
Guid printerId,
decimal gramsUsed,
decimal mmExtruded,
string? notes = null,
CancellationToken cancellationToken = default)
{
var usage = new FilamentUsage
{
PrintJobId = printJobId,
SpoolId = spoolId,
PrinterId = printerId,
GramsUsed = gramsUsed,
MmExtruded = mmExtruded,
RecordedAt = DateTime.UtcNow,
Notes = notes
};
_dbContext.FilamentUsages.Add(usage);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Recorded filament usage: {Grams}g / {Mm}mm for print job {JobId} on spool {SpoolId}",
gramsUsed, mmExtruded, printJobId, spoolId);
return usage;
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetByPrintJobAsync(
Guid printJobId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.PrintJobId == printJobId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FilamentUsage>> GetBySpoolAsync(
Guid spoolId,
CancellationToken cancellationToken = default)
{
return await _dbContext.FilamentUsages
.Where(u => u.SpoolId == spoolId)
.OrderByDescending(u => u.RecordedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,390 @@
using Extrudex.Domain.DTOs.Moonraker;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Configuration options for the Moonraker usage polling service.
/// </summary>
public class MoonrakerPollerOptions
{
/// <summary>
/// How often to poll each Moonraker printer for filament usage data.
/// Default: 30 seconds.
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Timeout for individual Moonraker HTTP requests.
/// Default: 10 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Whether the polling service is enabled. Default: true.
/// Set to false to disable polling (e.g., in development or testing).
/// </summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Background service that periodically polls Moonraker-connected printers
/// for filament usage data. When a print job is detected as complete,
/// the usage data is persisted to the FilamentUsage table via
/// <see cref="IFilamentUsageService"/>.
///
/// <para>Polling logic:</para>
/// <list type="number">
/// <item>Query the database for all active printers with ConnectionType == Moonraker.</item>
/// <item>For each printer, call <see cref="IMoonrakerClient.GetPrintStatsAsync"/> for live data
/// and <see cref="IMoonrakerClient.GetPrintHistoryAsync"/> for completed job history.</item>
/// <item>If usage data is available and the print state is "complete",
/// create or update a FilamentUsage record.</item>
/// <item>If the printer is unreachable or returns malformed data, log a warning
/// and continue to the next printer (no crash).</item>
/// </list>
///
/// <para>Error handling:</para>
/// <list type="bullet">
/// <item>API unreachable: logged as warning, poller continues for other printers.</item>
/// <item>Malformed response: logged as warning, poller continues.</item>
/// <item>Database errors: logged as error, poller continues.</item>
/// </list>
/// </summary>
public class MoonrakerUsagePoller : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MoonrakerUsagePoller> _logger;
private readonly MoonrakerPollerOptions _options;
/// <summary>
/// Tracks which Moonraker print jobs have already been recorded,
/// keyed by "printerId:gcodeFileName" to avoid duplicate recording.
/// </summary>
private readonly HashSet<string> _recordedJobs = new();
public MoonrakerUsagePoller(
IServiceScopeFactory scopeFactory,
ILogger<MoonrakerUsagePoller> logger,
IOptions<MoonrakerPollerOptions> options)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Moonraker usage poller is disabled via configuration.");
return;
}
_logger.LogInformation(
"Moonraker usage poller starting. Poll interval: {Interval}",
_options.PollInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollAllPrintersAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error in Moonraker usage poller cycle. Continuing.");
}
await Task.Delay(_options.PollInterval, stoppingToken);
}
_logger.LogInformation("Moonraker usage poller stopping.");
}
private async Task PollAllPrintersAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ExtrudexDbContext>();
var moonrakerClient = scope.ServiceProvider.GetRequiredService<IMoonrakerClient>();
var usageService = scope.ServiceProvider.GetRequiredService<IFilamentUsageService>();
var printers = await dbContext.Printers
.Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker)
.ToListAsync(cancellationToken);
if (printers.Count == 0)
{
_logger.LogDebug("No active Moonraker printers found.");
return;
}
_logger.LogDebug("Polling {Count} Moonraker printer(s).", printers.Count);
foreach (var printer in printers)
{
await PollPrinterAsync(
printer, moonrakerClient, usageService, dbContext, cancellationToken);
}
}
private async Task PollPrinterAsync(
Printer printer,
IMoonrakerClient moonrakerClient,
IFilamentUsageService usageService,
ExtrudexDbContext dbContext,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Polling Moonraker printer {PrinterName} ({Host}:{Port})",
printer.Name, printer.HostnameOrIp, printer.Port);
try
{
var printStats = await moonrakerClient.GetPrintStatsAsync(
printer.HostnameOrIp,
printer.Port,
printer.ApiKey,
cancellationToken);
if (printStats is null)
{
_logger.LogDebug(
"No print stats available from printer {PrinterName}.", printer.Name);
return;
}
printer.LastSeenAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogDebug(
"Printer {PrinterName}: state={State}, filament={Mm}mm, file={File}",
printer.Name, printStats.State, printStats.FilamentUsedMm, printStats.Filename);
decimal mmExtruded = printStats.FilamentUsedMm;
if (mmExtruded <= 0)
{
_logger.LogDebug(
"Printer {PrinterName} has no filament usage to record.", printer.Name);
return;
}
if (!IsCompleteState(printStats.State))
{
_logger.LogDebug(
"Printer {PrinterName} print state '{State}' is not complete; skipping.",
printer.Name, printStats.State);
return;
}
string gcodeFileName = printStats.Filename ?? $"unknown-{Guid.NewGuid():N}";
var deduplicationKey = $"{printer.Id}:{gcodeFileName}";
if (_recordedJobs.Contains(deduplicationKey))
{
_logger.LogDebug(
"Printer {PrinterName} job '{File}' already recorded; skipping.",
printer.Name, gcodeFileName);
return;
}
DateTime? startedAt = null;
DateTime? completedAt = null;
try
{
var history = await moonrakerClient.GetPrintHistoryAsync(
printer.HostnameOrIp, printer.Port, printer.ApiKey,
limit: 1, cancellationToken);
if (history.Items.Count > 0)
{
var latestJob = history.Items[0];
startedAt = latestJob.StartTime;
completedAt = latestJob.EndTime;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Could not fetch history for printer {PrinterName}; proceeding with stats only.",
printer.Name);
}
var printJob = await FindOrCreatePrintJobAsync(
dbContext, printer, mmExtruded, gcodeFileName,
startedAt, completedAt, cancellationToken);
if (printJob is null)
{
_logger.LogWarning(
"Could not find or create print job for printer {PrinterName}. No active spool found.",
printer.Name);
return;
}
var spool = await dbContext.Spools.FindAsync(
new object[] { printJob.SpoolId }, cancellationToken);
var gramsUsed = CalculateGramsUsed(mmExtruded, spool);
await usageService.RecordUsageAsync(
printJobId: printJob.Id,
spoolId: printJob.SpoolId,
printerId: printer.Id,
gramsUsed: gramsUsed,
mmExtruded: mmExtruded,
notes: $"Moonraker auto-recorded: {gcodeFileName}",
cancellationToken: cancellationToken);
_recordedJobs.Add(deduplicationKey);
_logger.LogInformation(
"Recorded Moonraker usage for printer {PrinterName}: {Mm}mm / {Grams}g, job '{File}'",
printer.Name, mmExtruded, gramsUsed, gcodeFileName);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex,
"Moonraker API unreachable for printer {PrinterName} ({Host}:{Port}). Will retry next cycle.",
printer.Name, printer.HostnameOrIp, printer.Port);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex,
"Moonraker request timed out for printer {PrinterName} ({Host}:{Port}).",
printer.Name, printer.HostnameOrIp, printer.Port);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error polling Moonraker printer {PrinterName}. Continuing to next printer.",
printer.Name);
}
}
private static bool IsCompleteState(string state) =>
state.Equals("complete", StringComparison.OrdinalIgnoreCase) ||
state.Equals("completed", StringComparison.OrdinalIgnoreCase);
private async Task<PrintJob?> FindOrCreatePrintJobAsync(
ExtrudexDbContext dbContext,
Printer printer,
decimal mmExtruded,
string gcodeFileName,
DateTime? startedAt,
DateTime? completedAt,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(gcodeFileName))
{
var existingJob = await dbContext.PrintJobs
.Where(j => j.PrinterId == printer.Id &&
j.GcodeFilePath == gcodeFileName &&
j.DataSource == DataSource.Moonraker &&
j.Status != JobStatus.Cancelled)
.OrderByDescending(j => j.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existingJob is not null)
{
existingJob.MmExtruded = mmExtruded;
existingJob.GramsDerived = CalculateGramsUsed(
mmExtruded,
await dbContext.Spools.FindAsync(
new object[] { existingJob.SpoolId }, cancellationToken));
existingJob.Status = JobStatus.Completed;
existingJob.CompletedAt = completedAt ?? DateTime.UtcNow;
existingJob.StartedAt ??= startedAt;
await dbContext.SaveChangesAsync(cancellationToken);
return existingJob;
}
}
var spool = await FindActiveSpoolForPrinterAsync(dbContext, printer, cancellationToken);
if (spool is null) return null;
var gramsDerived = CalculateGramsUsed(mmExtruded, spool);
var newJob = new PrintJob
{
PrinterId = printer.Id,
SpoolId = spool.Id,
PrintName = gcodeFileName ?? "Moonraker Print",
GcodeFilePath = gcodeFileName,
MmExtruded = mmExtruded,
GramsDerived = gramsDerived,
FilamentDiameterAtPrintMm = spool.FilamentDiameterMm,
MaterialDensityAtPrint = GetMaterialDensity(spool),
DataSource = DataSource.Moonraker,
Status = JobStatus.Completed,
StartedAt = startedAt ?? DateTime.UtcNow,
CompletedAt = completedAt ?? DateTime.UtcNow,
Notes = "Auto-created by Moonraker usage poller"
};
dbContext.PrintJobs.Add(newJob);
await dbContext.SaveChangesAsync(cancellationToken);
return newJob;
}
private static async Task<Spool?> FindActiveSpoolForPrinterAsync(
ExtrudexDbContext dbContext,
Printer printer,
CancellationToken cancellationToken)
{
var amsSpool = await dbContext.AmsSlots
.Include(s => s.Spool)
.ThenInclude(s => s!.MaterialBase)
.Include(s => s.AmsUnit)
.Where(s => s.AmsUnit.PrinterId == printer.Id && s.Spool != null && s.Spool.IsActive)
.Select(s => s.Spool)
.FirstOrDefaultAsync(cancellationToken);
if (amsSpool is not null) return amsSpool;
return await dbContext.Spools
.Include(s => s.MaterialBase)
.Where(s => s.IsActive)
.OrderByDescending(s => s.WeightRemainingGrams)
.FirstOrDefaultAsync(cancellationToken);
}
private static decimal CalculateGramsUsed(decimal mmExtruded, Spool? spool)
{
if (spool is null) return 0m;
var diameterMm = spool.FilamentDiameterMm;
var densityGcm3 = GetMaterialDensity(spool);
var radiusMm = diameterMm / 2m;
var crossSectionArea = Math.PI * (double)radiusMm * (double)radiusMm;
var volumeMm3 = (double)mmExtruded * crossSectionArea;
var volumeCm3 = volumeMm3 / 1000.0;
var grams = volumeCm3 * (double)densityGcm3;
return Math.Round((decimal)grams, 2);
}
private static decimal GetMaterialDensity(Spool? spool)
{
return spool?.MaterialBase?.Name?.ToUpperInvariant() switch
{
"PLA" => 1.24m,
"PETG" => 1.27m,
"ABS" => 1.04m,
"ASA" => 1.07m,
"TPU" => 1.21m,
"NYLON" or "PA" => 1.13m,
"PC" => 1.20m,
_ => 1.24m
};
}
}

View File

@@ -61,6 +61,14 @@ builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
// ── Usage Logging ───────────────────────────────────────────
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
// ── Filament Usage Service ──────────────────────────────────
builder.Services.AddScoped<IFilamentUsageService, FilamentUsageService>();
// ── Moonraker Usage Poller (Background Service) ─────────────
builder.Services.Configure<MoonrakerPollerOptions>(
builder.Configuration.GetSection("MoonrakerPoller"));
builder.Services.AddHostedService<MoonrakerUsagePoller>();
// ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

View File

@@ -23,5 +23,11 @@
},
"FilamentAlerts": {
"LowStockThresholdPercent": 20
},
"MoonrakerPoller": {
"Enabled": true,
"PollInterval": "00:00:30",
"RequestTimeout": "00:00:10"
}
}
}

View File

@@ -0,0 +1,517 @@
# Extrudex Mobile-First Design System
> **Project:** CUB-71 — Mobile-First Touch-Optimized UI Design
> **Parent:** CUB-67 — Extrudex Mobile-First PWA Implementation
> **Date:** 2026-05-01
> **Author:** Sketch
> **Status:** In Progress
---
## 1. Design Philosophy
### Mobile-First, Touch-Always
- **Minimum touch target:** 44×44px (Apple HIG) / 48×48dp (Material Design)
- **Thumb-friendly zones:** Primary actions in bottom 25% of screen
- **Gesture support:** Swipe, pull-to-refresh, pinch-to-zoom on relevant screens
- **One-handed use:** All critical actions reachable within thumb zone
### Workshop-Ready
- **High contrast:** WCAG AA minimum (4.5:1) — workshop lighting varies
- **Large text:** 16sp minimum body, 20sp+ for primary info
- **Glanceable:** Key info visible at arm's length (3ft)
- **Glove-compatible:** Larger tap targets for operators wearing work gloves
### Speed Over Complexity
- **Sub-second load:** Cached assets, skeleton screens
- **Minimal taps:** Every flow optimized for fewest interactions
- **Smart defaults:** Pre-filled forms based on context
---
## 2. Color System
### Primary Palette (Dark Theme — Default)
| Token | Hex | Usage |
|-------|-----|-------|
| `surface` | `#1C1B1F` | Screen background |
| `onSurface` | `#E6E1E5` | Primary text |
| `surfaceContainer` | `#211F26` | Cards, app bar |
| `surfaceContainerHigh` | `#2B2930` | Elevated cards |
| `primary` | `#A8CEDA` | FAB, active states, links |
| `onPrimary` | `#00303E` | Text on primary |
| `primaryContainer` | `#004D63` | Chip fills, tonal buttons |
| `onPrimaryContainer` | `#A8CEDA` | Text on primary container |
| `secondary` | `#B1CCC7` | Secondary actions |
| `error` | `#F2B8B5` | Critical stock, errors |
| `errorContainer` | `#8C1D18` | Error backgrounds |
| `warning` | `#FFD580` | Low stock warning |
| `warningContainer` | `#5D4200` | Warning backgrounds |
| `success` | `#8BD0A0` | Available, success states |
| `successContainer` | `#00522E` | Success backgrounds |
| `outline` | `#938F99` | Borders, dividers |
| `outlineVariant` | `#49454F` | Subtle borders |
### Light Theme (Optional)
| Token | Hex | Usage |
|-------|-----|-------|
| `surface` | `#F8FAFC` | Screen background |
| `surfaceContainer` | `#FFFFFF` | Cards |
| `primary` | `#2563EB` | Primary actions |
| `onSurface` | `#0F172A` | Primary text |
---
## 3. Typography Scale
### Mobile Type Scale
| Role | Token | Size | Weight | Line Height | Letter Spacing |
|------|-------|------|--------|-------------|----------------|
| Display Large | `displayLarge` | 57sp | 400 | 64sp | -0.25px |
| Display Medium | `displayMedium` | 45sp | 400 | 52sp | 0 |
| Headline Large | `headlineLarge` | 32sp | 400 | 40sp | 0 |
| Headline Medium | `headlineMedium` | 28sp | 400 | 36sp | 0 |
| Headline Small | `headlineSmall` | 24sp | 400 | 32sp | 0 |
| Title Large | `titleLarge` | 22sp | 400 | 28sp | 0 |
| Title Medium | `titleMedium` | 16sp | 500 | 24sp | 0.15px |
| Title Small | `titleSmall` | 14sp | 500 | 20sp | 0.1px |
| Body Large | `bodyLarge` | 16sp | 400 | 24sp | 0.5px |
| Body Medium | `bodyMedium` | 14sp | 400 | 20sp | 0.25px |
| Label Large | `labelLarge` | 14sp | 500 | 20sp | 0.1px |
| Label Medium | `labelMedium` | 12sp | 500 | 16sp | 0.5px |
| Label Small | `labelSmall` | 11sp | 500 | 16sp | 0.5px |
### Font Stack
```css
--font-sans: 'Inter', 'Roboto', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Roboto Mono', monospace;
```
---
## 4. Spacing System
### 8dp Grid Base
| Token | Value | Usage |
|-------|-------|-------|
| `space-1` | 4dp | Tight padding, icon gaps |
| `space-2` | 8dp | Default gap, small padding |
| `space-3` | 12dp | Card padding (compact) |
| `space-4` | 16dp | Standard padding, section gaps |
| `space-5` | 20dp | Large padding |
| `space-6` | 24dp | Section margins, hero padding |
| `space-8` | 32dp | Large section gaps |
| `space-10` | 40dp | Major section spacing |
| `space-12` | 48dp | Touch target minimum |
| `space-16` | 64dp | App bar height |
### Border Radius
| Token | Value | Usage |
|-------|-------|-------|
| `radius-sm` | 4dp | Small buttons, chips |
| `radius-md` | 8dp | Cards, inputs |
| `radius-lg` | 12dp | Modals, bottom sheets |
| `radius-xl` | 16dp | Large cards, dialogs |
| `radius-full` | 9999px | Pills, circular elements |
---
## 5. Component Specifications
### 5.1 Bottom Navigation Bar
**Height:** 80dp
**Background:** `surfaceContainer` with 1dp top outline
**Layout:** 4-5 icon+label items, evenly distributed
**Active state:** Icon filled + `primary` color, label `primary`
**Inactive state:** Icon outlined + `onSurfaceVariant` color
**Touch target:** Full 80dp height, icon+label centered
```
┌─────────────────────────────────────┐
│ 📦 🖨️ 📋 ⚙️ │
│Inventory Printers Jobs Settings │
└─────────────────────────────────────┘
```
**Destinations:**
- Inventory (spools list) — default active
- Printers (printer fleet status)
- Jobs (print job history)
- Settings (app configuration)
---
### 5.2 QR Scanner Interface
**Layout:** Full-bleed camera viewport with overlay
```
┌─────────────────────────────────────┐
│ ✕ Scan Spool [⚡] │ ← App Bar (56dp)
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ ┌───────────────────┐ │ │
│ │ │ ╔═══════════════╗ │ │ │
│ │ │ ║ ▄▄▄▄▄▄▄▄▄▄▄ ║ │ │ │
│ │ │ ║ █ VIEWPORT █ ║ │ │ │
│ │ │ ║ █ FRAME █ ║ │ │ │
│ │ │ ║ ▀▀▀▀▀▀▀▀▀▀▀ ║ │ │ │
│ │ │ ║ (scan line) ║ │ │ │
│ │ └───────────────────┘ │ │
│ │ │ │
│ │ LIVE CAMERA FEED │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ [Last scan: 8901234567890] │
│ │
│ Can't scan? Enter manually │
│ │
└─────────────────────────────────────┘
```
**Elements:**
- **Camera viewport:** Full width, 60% screen height, `surfaceContainerHighest` background before camera starts
- **Scanning frame:** 200dp × 120dp centered, corner brackets (3dp stroke, `primary`)
- **Scan line:** Horizontal 2dp line, sweeps top→bottom (2s cycle), `primary` at 40% opacity
- **Scan result chip:** Appears below viewport, monospace text, auto-advances in 1.5s
- **Manual entry:** Text button fallback
**States:**
| State | Visual |
|-------|--------|
| Initializing | Shimmer loading + "Starting camera…" |
| Ready | Live feed + animated scan line |
| Scanning | Frame flashes `success` green |
| No match | Frame flashes `warning` yellow |
| Camera denied | Error card + "Enter manually" button |
---
### 5.3 Spool Detail View (Mobile)
**Layout:** Scrollable vertical stack
```
┌─────────────────────────────────────┐
│ ← PLA Basic — Matte Black [✏] │ ← App Bar
├─────────────────────────────────────┤
│ │
│ ┌──────┐ │
│ │SWATCH│ PLA Basic — Matte │
│ │ 80dp │ Black [Avail] │
│ └──────┘ Bambu Lab │
│ │
│ ◉ 842g / 1000g (67%) │ ← Circular progress
│ │
├─────────────────────────────────────┤
│ Total Weight │ Remaining │
│ 1,000g │ 842g (67%) │
├─────────────────────────────────────┤
│ Diameter │ Density │
│ 1.75mm │ 1.24 g/cm³ │
├─────────────────────────────────────┤
│ Date Added │ Last Used │
│ Mar 12, 2026 │ Apr 18, 2026 │
├─────────────────────────────────────┤
│ 📍 AMS Unit 2, Slot A3 [Move] │
├─────────────────────────────────────┤
│ ┌──────────┐ │
│ │ QR CODE │ │
│ │ 160dp │ EXT-2026-PLA-0042 │
│ └──────────┘ [Reprint] │
├─────────────────────────────────────┤
│ Recent Usage │
│ Print #1847 — Benchy -32g │
│ Print #1842 — Housing -18g │
│ [View Full History →] │
├─────────────────────────────────────┤
│ │
│ [Depleted] [Move] [Print] │ ← Sticky action row
│ │
├─────────────────────────────────────┤
│ 📦 🖨️ 📋 ⚙️ │ ← Bottom nav
└─────────────────────────────────────┘
```
**Hero Section:**
- Color swatch: 80dp circle, `outlineVariant` border
- Material name: `headlineMedium` (28sp)
- Brand: `bodyLarge` (16sp), `onSurfaceVariant`
- Status badge: Tonal chip
- Circular progress: 96dp diameter, dynamic color based on percentage
**Metrics Grid:**
- 2-column grid on mobile, 3-column on kiosk
- Label: `labelMedium` (12sp)
- Value: `titleMedium` (16sp)
**Action Row (Sticky):**
- Height: 64dp minimum
- Buttons: 48dp height minimum
- Deplete: Outlined, `error` color
- Move: Tonal, `secondary` color
- Print: Filled, `primary` color
---
### 5.4 Print-Log Form (Quick Entry)
**Layout:** Bottom sheet or full-screen
```
┌─────────────────────────────────────┐
│ ✕ Log Print Usage │
├─────────────────────────────────────┤
│ │
│ Spool: PLA Basic — Matte Black │
│ Remaining: 842g │
│ │
│ Weight Used │
│ ┌─────────────────────────────┐ │
│ │ [] 32 [+] grams │ │ ← Stepper
│ └─────────────────────────────┘ │
│ │
│ Quick: [10] [25] [50] [100] │
│ │
│ Print Job (optional) │
│ ┌─────────────────────────────┐ │
│ │ Select or enter job name... │ │
│ └─────────────────────────────┘ │
│ │
│ Notes (optional) │
│ ┌─────────────────────────────┐ │
│ │ ... │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✓ Log Print (842→810g) │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
**Design Principles:**
- **Number pad first:** Show numeric keyboard by default
- **Quick-select chips:** Common values (10g, 25g, 50g, 100g)
- **Stepper control:** ± buttons flanking input
- **Live preview:** Show remaining weight after deduction
- **Single-tap complete:** Primary action always visible
---
### 5.5 Status Badges
| Status | Background | Text | Icon |
|--------|-----------|------|------|
| Available | `successContainer` | `success` | check_circle |
| In Use | `primaryContainer` | `primary` | print |
| Low Stock | `warningContainer` | `warning` | warning |
| Critical | `errorContainer` | `error` | error |
| Depleted | `errorContainer` | `error` | remove_circle |
---
### 5.6 Touch Targets & Accessibility
**Minimum Sizes:**
- Interactive elements: 48×48dp
- List items: 56dp height minimum
- Buttons: 48dp height, 88dp minimum width
- Chips: 32dp height, 48dp minimum width
- Input fields: 56dp height
**Focus States:**
- 2dp outline, `primary` color, 2dp offset
- Visible on keyboard navigation
**Motion:**
- Respect `prefers-reduced-motion`
- Disable animations when set
- Use opacity fades instead of transforms
---
## 6. Responsive Breakpoints
| Breakpoint | Width | Target |
|-----------|-------|--------|
| Compact | 0-599dp | Mobile phones |
| Medium | 600-839dp | Large phones, small tablets |
| Expanded | 840-1199dp | Tablets, small desktops |
| Large | 1200dp+ | Desktops, kiosks |
### Layout Adaptations
| Property | Compact (Mobile) | Large (Kiosk) |
|----------|-----------------|---------------|
| Navigation | Bottom bar (80dp) | Rail (80dp, left) |
| Content padding | 16dp | 24dp |
| Grid columns | 1-2 | 2-3 |
| Card padding | 16dp | 20dp |
| Typography scale | Standard | +10% for distance viewing |
| Touch targets | 48dp | 56dp (glove-friendly) |
---
## 7. Animation & Motion
### Principles
- **Purposeful:** Every animation guides attention or provides feedback
- **Fast:** 200-300ms standard duration
- **Easing:** `ease-out` for entrances, `ease-in-out` for transitions
### Patterns
| Animation | Duration | Easing | Use Case |
|-----------|----------|--------|----------|
| Slide in | 300ms | ease-out | Screen transitions |
| Fade | 200ms | ease-in-out | Modal/dialog |
| Scale | 200ms | ease-out | Button presses |
| Shimmer | 1.5s | linear | Loading skeletons |
| Pulse | 2s | ease-in-out | Scan line, critical alerts |
| Progress | 600ms | ease-out | Circular progress fill |
### Interaction Feedback
- **Button press:** Scale to 0.96, 100ms
- **Chip select:** Background color transition, 150ms
- **List item tap:** Ripple effect, `primary` at 12% opacity
- **Success:** Green flash + haptic (mobile)
- **Error:** Red flash + shake animation
---
## 8. Assets & Icons
### Icon Set
- **Library:** Material Symbols (rounded)
- **Size:** 24dp default, 20dp in dense areas
- **Weight:** 400 (regular), filled for active states
### Required Icons
| Icon | Name | Usage |
|------|------|-------|
| 📦 | inventory_2 | Inventory nav |
| 🖨️ | print | Printers nav |
| 📋 | assignment | Jobs nav |
| ⚙️ | settings | Settings nav |
| ✕ | close | Close/dismiss |
| ← | arrow_back | Navigate back |
| ✏ | edit | Edit action |
| 🔍 | search | Search toggle |
| + | add | Add/create |
| 🗑 | delete | Delete action |
| 📷 | photo_camera | Scan action |
| ⚠ | warning | Warning states |
| ✓ | check_circle | Success states |
| 📍 | location_on | Location |
| 📶 | signal_cellular_alt | SignalR status |
---
## 9. Developer Handoff Checklist
- [x] Color tokens defined (dark theme)
- [x] Typography scale specified
- [x] Spacing system documented
- [x] Component specifications written
- [x] Touch targets validated (≥48dp)
- [x] Responsive breakpoints defined
- [x] Animation patterns documented
- [x] Icon set specified
- [ ] Mockup images generated (blocked — image generation unavailable)
- [ ] Figma/Sketch file created (optional)
- [ ] Design tokens exported to CSS/SCSS
---
## 10. Implementation Notes for Rex
### Priority Order
1. Bottom navigation component
2. QR scanner interface (camera + overlay)
3. Spool detail view (scrollable)
4. Print-log form (bottom sheet)
5. Status badges and indicators
### Angular Material Components Needed
```typescript
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
```
### QR Scanning Libraries
- **Mobile:** `html5-qrcode` or `@zxing/browser`
- **Camera access:** `navigator.mediaDevices.getUserMedia({ facingMode: 'environment' })`
### Key Files to Create
```
frontend/src/app/
├── components/
│ ├── bottom-nav/
│ │ ├── bottom-nav.component.ts
│ │ ├── bottom-nav.component.html
│ │ └── bottom-nav.component.scss
│ ├── qr-scanner/
│ │ ├── qr-scanner.component.ts
│ │ ├── qr-scanner.component.html
│ │ └── qr-scanner.component.scss
│ ├── spool-detail/
│ │ ├── spool-detail.component.ts
│ │ ├── spool-detail.component.html
│ │ └── spool-detail.component.scss
│ └── print-log-form/
│ ├── print-log-form.component.ts
│ ├── print-log-form.component.html
│ └── print-log-form.component.scss
├── design-system/
│ ├── _colors.scss
│ ├── _typography.scss
│ ├── _spacing.scss
│ └── _mixins.scss
└── models/
└── mobile-ui.model.ts
```
---
## 11. File Locations
| File | Path | Purpose |
|------|------|---------|
| Design System | `design/mobile-design-system.md` | This document |
| Inventory Spec | `design/01-filament-inventory-list.md` | Existing — inventory screen |
| Spool Detail Spec | `design/02-spool-detail-view.md` | Existing — detail screen |
| Smart Intake Spec | `design/03-smart-intake-workflow.md` | Existing — intake flow |
| Identify Spec | `design/smart-intake-identify-spec.md` | Existing — identify state |
| Mockup Images | `design/mockup-*.png/jpg` | Visual references |
---
**Next Steps:**
1. Review design system with stakeholders
2. Generate mockup images (when image generation is available)
3. Export design tokens to SCSS
4. Hand off to Rex for implementation
5. Create interactive prototype (optional)
**Status:** Design system document complete. Ready for implementation handoff.

View File

@@ -0,0 +1,225 @@
<!-- Filament Add/Edit Dialog — Angular Material Dialog -->
<mat-dialog-content class="filament-dialog-content">
<!-- Dialog Title -->
<h2 mat-dialog-title>{{ dialogTitle() }}</h2>
<!-- Loading state for lookup data -->
@if (lookupsLoading()) {
<div class="dialog-loading" role="status" aria-label="Loading material options">
<mat-spinner diameter="32"></mat-spinner>
<p>Loading material options…</p>
</div>
}
<!-- Form -->
@if (!lookupsLoading()) {
<form [formGroup]="form" class="filament-form" (ngSubmit)="save()">
<!-- Server Error Banner -->
@if (serverError()) {
<div class="error-banner" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
<span>{{ serverError() }}</span>
</div>
}
<!-- ── Material Section ──────────────────────────────── -->
<div class="form-section">
<h3 class="section-title">Material</h3>
<!-- Base Material -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Base Material</mat-label>
<mat-select formControlName="materialBaseId" required aria-label="Base material">
@for (base of materialBases(); track base.id) {
<mat-option [value]="base.id">{{ base.name }}</mat-option>
}
</mat-select>
@if (form.get('materialBaseId')!.hasError('required') && form.get('materialBaseId')!.touched) {
<mat-error>Base material is required</mat-error>
}
</mat-form-field>
<!-- Finish -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Finish</mat-label>
<mat-select formControlName="materialFinishId" required aria-label="Material finish">
<mat-option [value]="''" disabled>Select a base material first</mat-option>
@for (finish of filteredFinishes(); track finish.id) {
<mat-option [value]="finish.id">{{ finish.name }}</mat-option>
}
</mat-select>
@if (form.get('materialFinishId')!.hasError('required') && form.get('materialFinishId')!.touched) {
<mat-error>Finish is required</mat-error>
}
@if (filteredFinishes().length === 0 && form.get('materialBaseId')!.value) {
<mat-hint>No finishes available for this material</mat-hint>
}
</mat-form-field>
<!-- Modifier (optional) -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Modifier (optional)</mat-label>
<mat-select formControlName="materialModifierId" aria-label="Material modifier">
<mat-option [value]="null">None</mat-option>
@for (modifier of filteredModifiers(); track modifier.id) {
<mat-option [value]="modifier.id">{{ modifier.name }}</mat-option>
}
</mat-select>
@if (filteredModifiers().length === 0 && form.get('materialBaseId')!.value) {
<mat-hint>No modifiers available for this material</mat-hint>
}
</mat-form-field>
</div>
<!-- ── Spool Details Section ──────────────────────────── -->
<div class="form-section">
<h3 class="section-title">Spool Details</h3>
<!-- Brand -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Brand</mat-label>
<input matInput formControlName="brand" required maxlength="200"
placeholder="e.g., Bambu Lab, Polymaker" aria-label="Brand" />
@if (form.get('brand')!.hasError('required') && form.get('brand')!.touched) {
<mat-error>Brand is required</mat-error>
}
</mat-form-field>
<!-- Serial -->
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Serial Number</mat-label>
<input matInput formControlName="spoolSerial" required maxlength="200"
placeholder="e.g., SN-001" aria-label="Serial number" />
@if (form.get('spoolSerial')!.hasError('required') && form.get('spoolSerial')!.touched) {
<mat-error>Serial number is required</mat-error>
}
</mat-form-field>
<!-- Color Name + Color Hex (side by side) -->
<div class="form-row">
<mat-form-field appearance="outline" class="form-field">
<mat-label>Color Name</mat-label>
<input matInput formControlName="colorName" required maxlength="200"
placeholder="e.g., Fire Engine Red" aria-label="Color name" />
@if (form.get('colorName')!.hasError('required') && form.get('colorName')!.touched) {
<mat-error>Color name is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field color-hex-field">
<mat-label>Color Hex</mat-label>
<input matInput formControlName="colorHex" required
placeholder="#FF0000" maxlength="7" aria-label="Color hex code" />
<span matTextSuffix class="color-preview">
<span class="color-swatch-mini" [style.background-color]="form.get('colorHex')!.value"></span>
</span>
@if (form.get('colorHex')!.hasError('required') && form.get('colorHex')!.touched) {
<mat-error>Color hex is required</mat-error>
}
@if (form.get('colorHex')!.hasError('pattern') && form.get('colorHex')!.touched) {
<mat-error>Must be #RRGGBB format</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- ── Weight & Dimensions Section ────────────────────── -->
<div class="form-section">
<h3 class="section-title">Weight &amp; Dimensions</h3>
<div class="form-row">
<!-- Diameter -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Diameter (mm)</mat-label>
<input matInput type="number" formControlName="filamentDiameterMm" required
min="0.1" max="10" step="0.01" aria-label="Filament diameter in mm" />
@if (form.get('filamentDiameterMm')!.hasError('required') && form.get('filamentDiameterMm')!.touched) {
<mat-error>Diameter is required</mat-error>
}
</mat-form-field>
</div>
<div class="form-row">
<!-- Total Weight -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Total Weight (g)</mat-label>
<input matInput type="number" formControlName="weightTotalGrams" required
min="0.01" max="100000" step="1" aria-label="Total spool weight in grams" />
<mat-hint>Full spool weight</mat-hint>
@if (form.get('weightTotalGrams')!.hasError('required') && form.get('weightTotalGrams')!.touched) {
<mat-error>Total weight is required</mat-error>
}
</mat-form-field>
<!-- Remaining Weight -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Remaining Weight (g)</mat-label>
<input matInput type="number" formControlName="weightRemainingGrams" required
min="0" max="100000" step="1" aria-label="Remaining weight in grams" />
<mat-hint>Current remaining</mat-hint>
@if (form.get('weightRemainingGrams')!.hasError('required') && form.get('weightRemainingGrams')!.touched) {
<mat-error>Remaining weight is required</mat-error>
}
@if (form.get('weightRemainingGrams')!.hasError('exceedsTotal')) {
<mat-error>Cannot exceed total weight</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- ── Purchase & Status Section ──────────────────────── -->
<div class="form-section">
<h3 class="section-title">Purchase &amp; Status</h3>
<div class="form-row">
<!-- Purchase Price -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Price</mat-label>
<input matInput type="number" formControlName="purchasePrice"
min="0" max="1000000" step="0.01"
placeholder="e.g., 25.00" aria-label="Purchase price" />
<span matTextSuffix>$</span>
@if (form.get('purchasePrice')!.hasError('min') && form.get('purchasePrice')!.touched) {
<mat-error>Price must be non-negative</mat-error>
}
</mat-form-field>
<!-- Purchase Date -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Purchase Date</mat-label>
<input matInput [matDatepicker]="picker" formControlName="purchaseDate"
aria-label="Purchase date" />
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
</div>
<!-- Active Status -->
<div class="checkbox-row">
<mat-checkbox formControlName="isActive" aria-label="Active status">
Spool is active and available for use
</mat-checkbox>
</div>
</div>
</form>
}
</mat-dialog-content>
<!-- Dialog Actions -->
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="saving()"
aria-label="Cancel">
Cancel
</button>
<button mat-raised-button color="primary" type="button" (click)="save()"
[disabled]="saving() || form.invalid" aria-label="Save filament">
@if (saving()) {
<mat-spinner diameter="20" class="btn-spinner"></mat-spinner>
}
{{ isEditMode() ? 'Save Changes' : 'Add Filament' }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,175 @@
/**
* Filament Dialog Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
*/
$touch-target-min: 48px;
$spacing-unit: 8px;
$color-error: #ef4444;
// ── Dialog Layout ──────────────────────────────────────────
.filament-dialog-content {
overflow-y: auto;
max-height: 70vh;
padding: 0 $spacing-unit * 2;
@media (max-width: 480px) {
padding: 0 $spacing-unit;
}
}
[mat-dialog-title] {
margin: 0 0 $spacing-unit * 2 0;
padding: $spacing-unit * 2 0 0 0;
font-size: 20px;
font-weight: 600;
}
// ── Loading State ──────────────────────────────────────────
.dialog-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px $spacing-unit * 2;
color: var(--mat-sys-on-surface-variant);
p {
margin-top: $spacing-unit * 2;
font-size: 14px;
}
}
// ── Error Banner ───────────────────────────────────────────
.error-banner {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 1.5 $spacing-unit * 2;
border-radius: 8px;
margin-bottom: $spacing-unit * 2;
background-color: rgba($color-error, 0.12);
color: $color-error;
border: 1px solid rgba($color-error, 0.3);
font-size: 14px;
font-weight: 500;
mat-icon {
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
}
}
// ── Form Sections ──────────────────────────────────────────
.form-section {
margin-bottom: $spacing-unit * 3;
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mat-sys-on-surface-variant);
margin: 0 0 $spacing-unit * 1.5 0;
padding-bottom: $spacing-unit * 0.5;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
}
.filament-form {
display: flex;
flex-direction: column;
gap: $spacing-unit;
}
// ── Form Fields ────────────────────────────────────────────
.form-field {
width: 100%;
// Touch target sizing
.mat-mdc-form-field-subscript-wrapper {
min-height: 20px;
}
}
.form-row {
display: flex;
gap: $spacing-unit * 2;
width: 100%;
.form-field {
flex: 1;
}
@media (max-width: 480px) {
flex-direction: column;
gap: 0;
}
}
// ── Color Hex Preview ──────────────────────────────────────
.color-hex-field {
max-width: 180px;
@media (max-width: 480px) {
max-width: 100%;
}
}
.color-preview {
display: inline-flex;
align-items: center;
margin-left: 4px;
}
.color-swatch-mini {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
vertical-align: middle;
}
// ── Checkbox Row ───────────────────────────────────────────
.checkbox-row {
display: flex;
align-items: center;
padding: $spacing-unit 0;
mat-checkbox {
min-height: $touch-target-min;
display: flex;
align-items: center;
}
}
// ── Save Button Spinner ────────────────────────────────────
mat-dialog-actions {
padding: $spacing-unit $spacing-unit * 2 $spacing-unit * 2;
gap: $spacing-unit;
button {
min-height: $touch-target-min;
min-width: 100px;
}
}
.btn-spinner {
display: inline-block;
margin-right: $spacing-unit;
vertical-align: middle;
circle {
stroke: currentColor;
}
}

View File

@@ -0,0 +1,277 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Filament } from '../../models/filament.model';
import {
MaterialBase,
MaterialFinish,
MaterialModifier,
} from '../../models/material.model';
import {
FilamentService,
CreateFilamentRequest,
UpdateFilamentRequest,
} from '../../services/filament.service';
/** Data passed into the dialog from the opener. */
export interface FilamentDialogData {
/** If provided, the dialog opens in edit mode with pre-populated fields. */
filament?: Filament;
}
@Component({
selector: 'app-filament-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
],
templateUrl: './filament-dialog.component.html',
styleUrl: './filament-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilamentDialogComponent {
private readonly dialogRef = inject(MatDialogRef<FilamentDialogComponent>);
private readonly data = inject<FilamentDialogData>(MAT_DIALOG_DATA);
private readonly fb = inject(FormBuilder);
private readonly filamentService = inject(FilamentService);
/** Whether this dialog is in edit mode (has existing filament data). */
readonly isEditMode = computed(() => !!this.data.filament);
/** Dialog title based on mode. */
readonly dialogTitle = computed(() =>
this.isEditMode() ? 'Edit Filament' : 'Add Filament'
);
// ── Lookup data signals ──────────────────────────────────
/** All material bases for the base material dropdown. */
readonly materialBases = signal<MaterialBase[]>([]);
/** Material finishes filtered by selected base material. */
readonly filteredFinishes = signal<MaterialFinish[]>([]);
/** Material modifiers filtered by selected base material. */
readonly filteredModifiers = signal<MaterialModifier[]>([]);
/** Whether material lookups are loading. */
readonly lookupsLoading = signal(true);
/** Whether the save operation is in progress. */
readonly saving = signal(false);
/** Server error message, if any. */
readonly serverError = signal<string | null>(null);
// ── Form ─────────────────────────────────────────────────
readonly form: FormGroup = this.fb.group({
materialBaseId: ['', Validators.required],
materialFinishId: ['', Validators.required],
materialModifierId: [null],
brand: ['', [Validators.required, Validators.maxLength(200)]],
colorName: ['', [Validators.required, Validators.maxLength(200)]],
colorHex: ['#000000', [Validators.required, Validators.pattern(/^#[0-9A-Fa-f]{6}$/)]],
weightTotalGrams: [1000, [Validators.required, Validators.min(0.01), Validators.max(100000)]],
weightRemainingGrams: [1000, [Validators.required, Validators.min(0), Validators.max(100000)]],
filamentDiameterMm: [1.75, [Validators.required, Validators.min(0.1), Validators.max(10)]],
spoolSerial: ['', [Validators.required, Validators.maxLength(200)]],
purchasePrice: [null, [Validators.min(0), Validators.max(1000000)]],
purchaseDate: [null],
isActive: [true],
});
constructor() {
this.loadLookups();
this.patchFormIfEditing();
this.setupCascadingFilters();
}
// ── Data loading ─────────────────────────────────────────
/** Load material bases, finishes, and modifiers for dropdowns. */
private loadLookups(): void {
this.lookupsLoading.set(true);
this.filamentService.getMaterialBases().subscribe({
next: (bases) => {
this.materialBases.set(bases);
this.lookupsLoading.set(false);
},
error: () => {
this.lookupsLoading.set(false);
this.serverError.set('Failed to load material options. Please try again.');
},
});
}
/** Pre-populate form fields when editing an existing filament. */
private patchFormIfEditing(): void {
if (this.data.filament) {
const f = this.data.filament;
this.form.patchValue({
materialBaseId: f.materialBaseId,
materialFinishId: f.materialFinishId,
materialModifierId: f.materialModifierId,
brand: f.brand,
colorName: f.colorName,
colorHex: f.colorHex,
weightTotalGrams: f.weightTotalGrams,
weightRemainingGrams: f.weightRemainingGrams,
filamentDiameterMm: f.filamentDiameterMm,
spoolSerial: f.spoolSerial,
purchasePrice: f.purchasePrice,
purchaseDate: f.purchaseDate ? new Date(f.purchaseDate) : null,
isActive: f.isActive,
});
}
}
/** Set up cascading filter: when base material changes, reload finishes & modifiers. */
private setupCascadingFilters(): void {
this.form.get('materialBaseId')!.valueChanges.subscribe((baseId: string | null) => {
// Clear dependent selections when base changes
this.form.get('materialFinishId')!.setValue('');
this.form.get('materialModifierId')!.setValue(null);
this.filteredFinishes.set([]);
this.filteredModifiers.set([]);
if (!baseId) return;
this.filamentService.getMaterialFinishes(baseId).subscribe({
next: (finishes) => this.filteredFinishes.set(finishes),
error: () => this.filteredFinishes.set([]),
});
this.filamentService.getMaterialModifiers(baseId).subscribe({
next: (modifiers) => this.filteredModifiers.set(modifiers),
error: () => this.filteredModifiers.set([]),
});
});
// If editing, trigger the cascading load for the pre-selected base
if (this.data.filament) {
const baseId = this.data.filament.materialBaseId;
// We need to load finishes and modifiers for the pre-selected base
// but also re-select the original finish and modifier after loading
this.filamentService.getMaterialFinishes(baseId).subscribe({
next: (finishes) => {
this.filteredFinishes.set(finishes);
// Re-patch finish after load
this.form.get('materialFinishId')!.setValue(this.data.filament!.materialFinishId);
},
});
this.filamentService.getMaterialModifiers(baseId).subscribe({
next: (modifiers) => {
this.filteredModifiers.set(modifiers);
// Re-patch modifier after load
this.form.get('materialModifierId')!.setValue(this.data.filament!.materialModifierId);
},
});
}
}
// ── Actions ──────────────────────────────────────────────
/** Cancel and close the dialog without saving. */
cancel(): void {
this.dialogRef.close(false);
}
/** Submit the form — creates or updates the filament. */
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Cross-field validation: remaining weight must not exceed total weight
const total = this.form.value.weightTotalGrams;
const remaining = this.form.value.weightRemainingGrams;
if (remaining > total) {
this.form.get('weightRemainingGrams')!.setErrors({ exceedsTotal: true });
return;
}
this.saving.set(true);
this.serverError.set(null);
const formValue = this.form.value;
const request: CreateFilamentRequest | UpdateFilamentRequest = {
materialBaseId: formValue.materialBaseId,
materialFinishId: formValue.materialFinishId,
materialModifierId: formValue.materialModifierId || null,
brand: formValue.brand.trim(),
colorName: formValue.colorName.trim(),
colorHex: formValue.colorHex,
weightTotalGrams: formValue.weightTotalGrams,
weightRemainingGrams: formValue.weightRemainingGrams,
filamentDiameterMm: formValue.filamentDiameterMm,
spoolSerial: formValue.spoolSerial.trim(),
purchasePrice: formValue.purchasePrice ?? null,
purchaseDate: formValue.purchaseDate
? new Date(formValue.purchaseDate).toISOString()
: null,
isActive: formValue.isActive,
};
if (this.isEditMode()) {
const id = this.data.filament!.id;
this.filamentService.updateFilament(id, request).subscribe({
next: (updated) => {
this.saving.set(false);
this.dialogRef.close(true);
},
error: (err) => {
this.saving.set(false);
this.serverError.set(
err?.error?.error || err?.message || 'Failed to update filament. Please try again.'
);
},
});
} else {
this.filamentService.createFilament(request).subscribe({
next: (created) => {
this.saving.set(false);
this.dialogRef.close(true);
},
error: (err) => {
this.saving.set(false);
this.serverError.set(
err?.error?.error || err?.message || 'Failed to create filament. Please try again.'
);
},
});
}
}
}

View File

@@ -0,0 +1,50 @@
/**
* Material lookup models matching the Extrudex backend Material DTOs.
* Used for populating dropdowns in the filament add/edit form.
*/
/** Material base (e.g., PLA, PETG, ABS). */
export interface MaterialBase {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "PLA", "PETG"). */
name: string;
/** Density in g/cm³. */
densityGperCm3: number;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}
/** Material finish (e.g., Basic, Matte, Silk). */
export interface MaterialFinish {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "Basic", "Matte"). */
name: string;
/** Foreign key to the parent material base. */
materialBaseId: string;
/** Name of the parent material base (for display). */
materialBaseName: string;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}
/** Material modifier (e.g., Carbon Fiber, Wood Fill). Optional. */
export interface MaterialModifier {
/** Unique identifier. */
id: string;
/** Human-readable name (e.g., "Carbon Fiber"). */
name: string;
/** Foreign key to the parent material base. */
materialBaseId: string;
/** Name of the parent material base (for display). */
materialBaseName: string;
/** Created timestamp (UTC). */
createdAt: string;
/** Updated timestamp (UTC). */
updatedAt: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Generic paged response wrapper matching the Extrudex backend PagedResponse<T>.
*/
export interface PagedResponse<T> {
/** The items in this page. */
items: T[];
/** Total number of items across all pages. */
totalCount: number;
/** The current page number (1-based). */
pageNumber: number;
/** The number of items per page. */
pageSize: number;
}

View File

@@ -0,0 +1,8 @@
/**
* Environment configuration for the Extrudex frontend (production).
* Override API URL for deployed environments.
*/
export const environment = {
production: true,
apiBaseUrl: '/api',
};

View File

@@ -0,0 +1,8 @@
/**
* Environment configuration for the Extrudex frontend.
* Replace API URL with the actual backend endpoint in production.
*/
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:5000',
};