Compare commits
1 Commits
agent/sket
...
feca1e3ee9
| Author | SHA1 | Date | |
|---|---|---|---|
| feca1e3ee9 |
@@ -1,7 +1,6 @@
|
|||||||
using Extrudex.API.DTOs;
|
using Extrudex.API.DTOs;
|
||||||
using Extrudex.API.DTOs.Filaments;
|
using Extrudex.API.DTOs.Filaments;
|
||||||
using Extrudex.Domain.Entities;
|
using Extrudex.Domain.Entities;
|
||||||
using Extrudex.Domain.Interfaces;
|
|
||||||
using Extrudex.Infrastructure.Data;
|
using Extrudex.Infrastructure.Data;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -18,22 +17,16 @@ namespace Extrudex.API.Controllers;
|
|||||||
public class FilamentsController : ControllerBase
|
public class FilamentsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ExtrudexDbContext _dbContext;
|
private readonly ExtrudexDbContext _dbContext;
|
||||||
private readonly ILowStockDetector _lowStockDetector;
|
|
||||||
private readonly ILogger<FilamentsController> _logger;
|
private readonly ILogger<FilamentsController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
|
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dbContext">The database context for data access.</param>
|
/// <param name="dbContext">The database context for data access.</param>
|
||||||
/// <param name="lowStockDetector">The low-stock detection service for filament alerts.</param>
|
|
||||||
/// <param name="logger">The logger for diagnostic output.</param>
|
/// <param name="logger">The logger for diagnostic output.</param>
|
||||||
public FilamentsController(
|
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
|
||||||
ExtrudexDbContext dbContext,
|
|
||||||
ILowStockDetector lowStockDetector,
|
|
||||||
ILogger<FilamentsController> logger)
|
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_lowStockDetector = lowStockDetector;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +95,7 @@ public class FilamentsController : ControllerBase
|
|||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
|
.Select(s => MapToFilamentResponse(s))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var response = new PagedResponse<FilamentResponse>
|
var response = new PagedResponse<FilamentResponse>
|
||||||
@@ -143,7 +136,7 @@ public class FilamentsController : ControllerBase
|
|||||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(MapToFilamentResponse(spool, _lowStockDetector));
|
return Ok(MapToFilamentResponse(spool));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -218,7 +211,7 @@ public class FilamentsController : ControllerBase
|
|||||||
if (entity.MaterialModifierId.HasValue)
|
if (entity.MaterialModifierId.HasValue)
|
||||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||||
|
|
||||||
var response = MapToFilamentResponse(entity, _lowStockDetector);
|
var response = MapToFilamentResponse(entity);
|
||||||
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,37 +292,7 @@ public class FilamentsController : ControllerBase
|
|||||||
if (entity.MaterialModifierId.HasValue)
|
if (entity.MaterialModifierId.HasValue)
|
||||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||||
|
|
||||||
return Ok(MapToFilamentResponse(entity, _lowStockDetector));
|
return Ok(MapToFilamentResponse(entity));
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets only the filament spools that are flagged as low stock.
|
|
||||||
/// A spool is considered low stock when its remaining weight percentage
|
|
||||||
/// is at or below the configured threshold.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A list of low-stock filament spools with alert metadata.</returns>
|
|
||||||
/// <response code="200">Returns the list of low-stock filament spools.</response>
|
|
||||||
[HttpGet("low-stock")]
|
|
||||||
[ProducesResponseType(typeof(List<FilamentResponse>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ActionResult<List<FilamentResponse>>> GetLowStockFilaments()
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Getting low-stock filaments (threshold: {Threshold}%)",
|
|
||||||
_lowStockDetector.LowStockThresholdPercent);
|
|
||||||
|
|
||||||
var spools = await _dbContext.Spools
|
|
||||||
.Include(s => s.MaterialBase)
|
|
||||||
.Include(s => s.MaterialFinish)
|
|
||||||
.Include(s => s.MaterialModifier)
|
|
||||||
.Where(s => s.IsActive)
|
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var lowStockItems = spools
|
|
||||||
.Where(s => _lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams))
|
|
||||||
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(lowStockItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -398,12 +361,10 @@ public class FilamentsController : ControllerBase
|
|||||||
/// Maps a Spool domain entity to a FilamentResponse DTO.
|
/// Maps a Spool domain entity to a FilamentResponse DTO.
|
||||||
/// Denormalizes material names for display convenience.
|
/// Denormalizes material names for display convenience.
|
||||||
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
|
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
|
||||||
/// Calculates low-stock status and remaining weight percentage.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="s">The spool entity to map.</param>
|
/// <param name="s">The spool entity to map.</param>
|
||||||
/// <param name="lowStockDetector">The low-stock detection service for computing alert flags.</param>
|
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
|
||||||
/// <returns>A FilamentResponse DTO with denormalized material names, QR code URL, and low-stock metadata.</returns>
|
private static FilamentResponse MapToFilamentResponse(Spool s) => new()
|
||||||
private static FilamentResponse MapToFilamentResponse(Spool s, ILowStockDetector lowStockDetector) => new()
|
|
||||||
{
|
{
|
||||||
Id = s.Id,
|
Id = s.Id,
|
||||||
MaterialBaseId = s.MaterialBaseId,
|
MaterialBaseId = s.MaterialBaseId,
|
||||||
@@ -426,8 +387,6 @@ public class FilamentsController : ControllerBase
|
|||||||
StorageLocation = s.StorageLocation,
|
StorageLocation = s.StorageLocation,
|
||||||
CreatedAt = s.CreatedAt,
|
CreatedAt = s.CreatedAt,
|
||||||
UpdatedAt = s.UpdatedAt,
|
UpdatedAt = s.UpdatedAt,
|
||||||
QrCodeUrl = $"/api/qr/spool/{s.Id}",
|
QrCodeUrl = $"/api/qr/spool/{s.Id}"
|
||||||
IsLowStock = lowStockDetector.IsLowStock(s.WeightRemainingGrams, s.WeightTotalGrams),
|
|
||||||
RemainingWeightPercent = lowStockDetector.GetRemainingWeightPercent(s.WeightRemainingGrams, s.WeightTotalGrams)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -76,19 +76,6 @@ public class FilamentResponse
|
|||||||
/// Encodes a deep link to the spool's detail page.
|
/// Encodes a deep link to the spool's detail page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string QrCodeUrl { get; set; } = string.Empty;
|
public string QrCodeUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this spool is flagged as low stock — remaining weight is at or
|
|
||||||
/// below the configured low-stock threshold percentage.
|
|
||||||
/// Useful for UI alerts and inventory dashboards.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsLowStock { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remaining filament weight as a percentage of total weight (0–100).
|
|
||||||
/// Rounded to one decimal place. Returns 0 if total weight is zero.
|
|
||||||
/// </summary>
|
|
||||||
public decimal RemainingWeightPercent { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
namespace Extrudex.Domain.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detects low-stock filament spools based on configurable weight thresholds.
|
|
||||||
/// Determines whether a spool's remaining filament falls below a critical level
|
|
||||||
/// so that alerts and API flags can be surfaced to the user.
|
|
||||||
/// </summary>
|
|
||||||
public interface ILowStockDetector
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether a spool is considered low stock based on its remaining
|
|
||||||
/// weight relative to its total weight and the configured threshold percentage.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
|
|
||||||
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// <c>true</c> if the remaining weight percentage is at or below the configured
|
|
||||||
/// low-stock threshold; <c>false</c> otherwise. Returns <c>false</c> for spools
|
|
||||||
/// with zero total weight to avoid division-by-zero.
|
|
||||||
/// </returns>
|
|
||||||
bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates the remaining weight as a percentage of total weight.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="weightRemainingGrams">The current remaining weight in grams.</param>
|
|
||||||
/// <param name="weightTotalGrams">The total spool weight in grams when full.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// A value between 0 and 100 representing the percentage of filament remaining.
|
|
||||||
/// Returns 0 if total weight is zero to avoid division-by-zero.
|
|
||||||
/// </returns>
|
|
||||||
decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the currently configured low-stock threshold percentage.
|
|
||||||
/// Useful for API responses so clients know what threshold is in effect.
|
|
||||||
/// </summary>
|
|
||||||
decimal LowStockThresholdPercent { get; }
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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 Core–backed 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using Extrudex.Domain.Interfaces;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Extrudex.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Detects low-stock filament spools by comparing the remaining weight percentage
|
|
||||||
/// against a configurable threshold. The threshold can be set via:
|
|
||||||
/// 1. EXTRUDEX_LOW_STOCK_THRESHOLD env var (highest priority, e.g. "25")
|
|
||||||
/// 2. FilamentAlerts:LowStockThresholdPercent in appsettings.json
|
|
||||||
/// 3. Default: 20% (a standard spool is "low" when ≤20% remains)
|
|
||||||
/// </summary>
|
|
||||||
public class LowStockDetector : ILowStockDetector
|
|
||||||
{
|
|
||||||
private readonly ILogger<LowStockDetector> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The percentage threshold below which a spool is considered low stock.
|
|
||||||
/// For example, 20 means a spool is "low" when ≤20% of its filament remains.
|
|
||||||
/// </summary>
|
|
||||||
public decimal LowStockThresholdPercent { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="LowStockDetector"/> class.
|
|
||||||
/// Reads the low-stock threshold from configuration with env var override support.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="configuration">Application configuration for threshold settings.</param>
|
|
||||||
/// <param name="logger">Logger for diagnostic output.</param>
|
|
||||||
public LowStockDetector(IConfiguration configuration, ILogger<LowStockDetector> logger)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
// Priority: env var > appsettings > default (20%)
|
|
||||||
var envThreshold = Environment.GetEnvironmentVariable("EXTRUDEX_LOW_STOCK_THRESHOLD");
|
|
||||||
var configThreshold = configuration.GetValue<decimal?>("FilamentAlerts:LowStockThresholdPercent");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(envThreshold) && decimal.TryParse(envThreshold, out var parsedEnv))
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = Math.Clamp(parsedEnv, 0m, 100m);
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold set from env var EXTRUDEX_LOW_STOCK_THRESHOLD: {Threshold}%",
|
|
||||||
LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
else if (configThreshold.HasValue)
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = Math.Clamp(configThreshold.Value, 0m, 100m);
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold set from config FilamentAlerts:LowStockThresholdPercent: {Threshold}%",
|
|
||||||
LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
LowStockThresholdPercent = 20m;
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Low-stock threshold using default: {Threshold}%", LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsLowStock(decimal weightRemainingGrams, decimal weightTotalGrams)
|
|
||||||
{
|
|
||||||
if (weightTotalGrams <= 0m)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Spool with total weight {Total}g cannot be evaluated for low stock — treating as not low",
|
|
||||||
weightTotalGrams);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var remainingPercent = GetRemainingWeightPercent(weightRemainingGrams, weightTotalGrams);
|
|
||||||
var isLow = remainingPercent <= LowStockThresholdPercent;
|
|
||||||
|
|
||||||
if (isLow)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(
|
|
||||||
"Spool is LOW STOCK: {Remaining}g / {Total}g = {Percent:F1}% (threshold: {Threshold}%)",
|
|
||||||
weightRemainingGrams, weightTotalGrams, remainingPercent, LowStockThresholdPercent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isLow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public decimal GetRemainingWeightPercent(decimal weightRemainingGrams, decimal weightTotalGrams)
|
|
||||||
{
|
|
||||||
if (weightTotalGrams <= 0m)
|
|
||||||
return 0m;
|
|
||||||
|
|
||||||
return Math.Round(
|
|
||||||
(weightRemainingGrams / weightTotalGrams) * 100m,
|
|
||||||
1,
|
|
||||||
MidpointRounding.AwayFromZero);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,20 +55,9 @@ builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
|
|||||||
// ── Cost Per Print Calculation ─────────────────────────────
|
// ── Cost Per Print Calculation ─────────────────────────────
|
||||||
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
|
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
|
||||||
|
|
||||||
// ── Low Stock Detection ────────────────────────────────────
|
|
||||||
builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
|
|
||||||
|
|
||||||
// ── Usage Logging ───────────────────────────────────────────
|
// ── Usage Logging ───────────────────────────────────────────
|
||||||
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
|
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 ──────────────────────────────────────
|
// ── FluentValidation ──────────────────────────────────────
|
||||||
// Registers all validators from the API assembly into DI.
|
// Registers all validators from the API assembly into DI.
|
||||||
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|||||||
@@ -20,14 +20,5 @@
|
|||||||
"RequestTimeout": "00:00:15",
|
"RequestTimeout": "00:00:15",
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"HistoryBatchSize": 25
|
"HistoryBatchSize": 25
|
||||||
},
|
|
||||||
"FilamentAlerts": {
|
|
||||||
"LowStockThresholdPercent": 20
|
|
||||||
},
|
|
||||||
"MoonrakerPoller": {
|
|
||||||
"Enabled": true,
|
|
||||||
"PollInterval": "00:00:30",
|
|
||||||
"RequestTimeout": "00:00:10"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<!-- 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 & 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 & 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>
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
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.'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,35 +83,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Cost Column -->
|
|
||||||
<ng-container matColumnDef="cost">
|
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="cost">Cost</th>
|
|
||||||
<td mat-cell *matCellDef="let filament">
|
|
||||||
<div class="cost-cell">
|
|
||||||
@if (filament.purchasePrice !== null) {
|
|
||||||
<span class="cost-price">{{ formatCurrency(filament.purchasePrice) }}</span>
|
|
||||||
@let cpg = getCostPerGram(filament);
|
|
||||||
@if (cpg !== null) {
|
|
||||||
<span class="cost-per-gram">${{ cpg.toFixed(2) }}/g</span>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<span class="cost-unknown">—</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Usage Column -->
|
|
||||||
<ng-container matColumnDef="usage">
|
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="usage">Usage</th>
|
|
||||||
<td mat-cell *matCellDef="let filament">
|
|
||||||
<div class="usage-cell">
|
|
||||||
<span class="usage-grams">{{ formatWeight(getGramsUsed(filament)) }} used</span>
|
|
||||||
<span class="usage-remaining">{{ formatWeight(filament.weightRemainingGrams) }} left</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Stock Level Indicator Column -->
|
<!-- Stock Level Indicator Column -->
|
||||||
<ng-container matColumnDef="stockLevel">
|
<ng-container matColumnDef="stockLevel">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ $color-inactive: #94a3b8; // Gray — inactive spool
|
|||||||
// Table styling
|
// Table styling
|
||||||
.filament-table {
|
.filament-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 900px;
|
min-width: 700px;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -132,48 +132,6 @@ $color-inactive: #94a3b8; // Gray — inactive spool
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cost cell
|
|
||||||
.cost-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 80px;
|
|
||||||
|
|
||||||
.cost-price {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mat-sys-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cost-per-gram {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--mat-sys-on-surface-variant);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cost-unknown {
|
|
||||||
color: var(--mat-sys-on-surface-variant);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage cell
|
|
||||||
.usage-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 100px;
|
|
||||||
|
|
||||||
.usage-grams {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--mat-sys-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-remaining {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--mat-sys-on-surface-variant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining weight cell
|
// Remaining weight cell
|
||||||
.remaining-cell {
|
.remaining-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export type FilamentColumn =
|
|||||||
| 'brand'
|
| 'brand'
|
||||||
| 'serial'
|
| 'serial'
|
||||||
| 'remaining'
|
| 'remaining'
|
||||||
| 'cost'
|
|
||||||
| 'usage'
|
|
||||||
| 'stockLevel'
|
| 'stockLevel'
|
||||||
| 'status';
|
| 'status';
|
||||||
|
|
||||||
@@ -72,8 +70,6 @@ export class FilamentTableComponent implements OnInit {
|
|||||||
'brand',
|
'brand',
|
||||||
'serial',
|
'serial',
|
||||||
'remaining',
|
'remaining',
|
||||||
'cost',
|
|
||||||
'usage',
|
|
||||||
'stockLevel',
|
'stockLevel',
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
@@ -147,18 +143,6 @@ export class FilamentTableComponent implements OnInit {
|
|||||||
getRemainingPercent(b),
|
getRemainingPercent(b),
|
||||||
isAsc
|
isAsc
|
||||||
);
|
);
|
||||||
case 'cost':
|
|
||||||
return compare(
|
|
||||||
a.purchasePrice ?? 0,
|
|
||||||
b.purchasePrice ?? 0,
|
|
||||||
isAsc
|
|
||||||
);
|
|
||||||
case 'usage':
|
|
||||||
return compare(
|
|
||||||
a.weightTotalGrams - a.weightRemainingGrams,
|
|
||||||
b.weightTotalGrams - b.weightRemainingGrams,
|
|
||||||
isAsc
|
|
||||||
);
|
|
||||||
case 'stockLevel':
|
case 'stockLevel':
|
||||||
return compare(
|
return compare(
|
||||||
stockLevelOrder(classifyStockLevel(a)),
|
stockLevelOrder(classifyStockLevel(a)),
|
||||||
@@ -259,29 +243,6 @@ export class FilamentTableComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
return `${Math.round(grams)}g`;
|
return `${Math.round(grams)}g`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Template helper: format currency */
|
|
||||||
formatCurrency(value: number): string {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Template helper: compute cost per gram for a filament */
|
|
||||||
getCostPerGram(filament: Filament): number | null {
|
|
||||||
if (filament.purchasePrice === null || filament.purchasePrice === 0 || filament.weightTotalGrams <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return filament.purchasePrice / filament.weightTotalGrams;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Template helper: compute grams used for a filament */
|
|
||||||
getGramsUsed(filament: Filament): number {
|
|
||||||
return filament.weightTotalGrams - filament.weightRemainingGrams;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compare helper for sorting */
|
/** Compare helper for sorting */
|
||||||
|
|||||||
@@ -87,43 +87,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Average Cost per Gram -->
|
|
||||||
@if (avgCostPerGram() !== null) {
|
|
||||||
<div class="summary-item metric-card"
|
|
||||||
matTooltip="Average cost per gram across priced, active spools"
|
|
||||||
matTooltipPosition="below">
|
|
||||||
<mat-icon aria-hidden="true" class="metric-icon">scale</mat-icon>
|
|
||||||
<div class="metric-content">
|
|
||||||
<span class="metric-value">${{ avgCostPerGram()!.toFixed(2) }}/g</span>
|
|
||||||
<span class="metric-label">Avg Cost/g</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Total Usage -->
|
|
||||||
<div class="summary-item metric-card"
|
|
||||||
matTooltip="Total filament used across all spools"
|
|
||||||
matTooltipPosition="below">
|
|
||||||
<mat-icon aria-hidden="true" class="metric-icon">trending_down</mat-icon>
|
|
||||||
<div class="metric-content">
|
|
||||||
<span class="metric-value">{{ formatWeight(totalGramsUsed()) }}</span>
|
|
||||||
<span class="metric-label">Total Used</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estimated Used Value -->
|
|
||||||
@if (estimatedUsedValue() !== null) {
|
|
||||||
<div class="summary-item metric-card"
|
|
||||||
matTooltip="Estimated value of filament consumed"
|
|
||||||
matTooltipPosition="below">
|
|
||||||
<mat-icon aria-hidden="true" class="metric-icon">receipt_long</mat-icon>
|
|
||||||
<div class="metric-content">
|
|
||||||
<span class="metric-value">{{ formatCurrency(estimatedUsedValue()!) }}</span>
|
|
||||||
<span class="metric-label">Used Value</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Overall Remaining Stock Bar -->
|
<!-- Overall Remaining Stock Bar -->
|
||||||
<div class="summary-item metric-card stock-bar-card"
|
<div class="summary-item metric-card stock-bar-card"
|
||||||
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"
|
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"
|
||||||
|
|||||||
@@ -88,37 +88,6 @@ export class InventorySummaryComponent implements OnInit, OnDestroy {
|
|||||||
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0)
|
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Computed: average cost per gram across active spools with a price */
|
|
||||||
readonly avgCostPerGram = computed(() => {
|
|
||||||
const priced = this.filaments().filter(
|
|
||||||
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
|
|
||||||
);
|
|
||||||
if (priced.length === 0) return null;
|
|
||||||
const totalCost = priced.reduce((sum, f) => sum + f.purchasePrice!, 0);
|
|
||||||
const totalWeight = priced.reduce((sum, f) => sum + f.weightTotalGrams, 0);
|
|
||||||
return totalWeight > 0 ? totalCost / totalWeight : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Computed: total grams used across all spools */
|
|
||||||
readonly totalGramsUsed = computed(() =>
|
|
||||||
this.filaments().reduce(
|
|
||||||
(sum, f) => sum + (f.weightTotalGrams - f.weightRemainingGrams),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Computed: total estimated value of used filament */
|
|
||||||
readonly estimatedUsedValue = computed(() => {
|
|
||||||
const priced = this.filaments().filter(
|
|
||||||
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
|
|
||||||
);
|
|
||||||
if (priced.length === 0) return null;
|
|
||||||
return priced.reduce((sum, f) => {
|
|
||||||
const usedFraction = (f.weightTotalGrams - f.weightRemainingGrams) / f.weightTotalGrams;
|
|
||||||
return sum + f.purchasePrice! * usedFraction;
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Computed: total remaining weight across all spools in grams */
|
/** Computed: total remaining weight across all spools in grams */
|
||||||
readonly totalRemainingGrams = computed(() =>
|
readonly totalRemainingGrams = computed(() =>
|
||||||
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0)
|
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0)
|
||||||
@@ -200,8 +169,8 @@ export class InventorySummaryComponent implements OnInit, OnDestroy {
|
|||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Environment configuration for the Extrudex frontend (production).
|
|
||||||
* Override API URL for deployed environments.
|
|
||||||
*/
|
|
||||||
export const environment = {
|
|
||||||
production: true,
|
|
||||||
apiBaseUrl: '/api',
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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',
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user