Compare commits

..

17 Commits

Author SHA1 Message Date
cubecraft-agents[bot]
d93c6cd919 CUB-71: Add mobile-first design system document 2026-05-01 12:58:15 -04:00
42285c5dac Merge pull request 'CUB-33: Integrate Moonraker filament usage polling' (#33) from agent/dex/CUB-33-moonraker-usage-polling-v2 into dev
Some checks failed
Dev Build / build-test (push) Failing after 2m26s
2026-04-29 17:18:05 -04:00
9cd619b5ee CUB-33: integrate Moonraker filament usage polling
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m21s
2026-04-29 11:50:18 -04:00
ddae95767f Merge pull request 'CUB-35: Build add/edit filament modal' (#20) from agent/rex/CUB-35-filament-add-edit-modal into dev
Some checks failed
Dev Build / build-test (push) Failing after 4m26s
2026-04-29 11:29:32 -04:00
15187cab65 CUB-35: build add/edit filament modal with Angular Material Dialog
Some checks failed
Dev Build / build-test (pull_request) Failing after 2m28s
2026-04-29 11:16:15 -04:00
9112f78641 Merge pull request 'CUB-32: Add usage logging service' (#11) from agent/dex/CUB-32-usage-logging-service into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m48s
Dev Build / build-test (pull_request) Successful in 2m15s
2026-04-29 10:51:36 -04:00
57157ad947 CUB-32: Add usage logging service with EF Core entity, service, controller, and migration
All checks were successful
Dev Build / build-test (pull_request) Successful in 3m11s
2026-04-29 10:23:31 -04:00
a2707e02ee Merge pull request 'CUB-38: Implement low filament alert logic with configurable threshold' (#17) from agent/dex/CUB-38-low-filament-alert into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m14s
2026-04-29 10:11:49 -04:00
9192ece040 CUB-38: implement low filament alert logic with configurable threshold
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m12s
2026-04-28 12:42:03 +00:00
fa4a4c21b3 Merge pull request 'CUB-42: Show filament cost and usage in UI' (#31) from agent/rex/CUB-42-filament-cost-usage-ui into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m10s
Reviewed-on: #31
Reviewed-by: Joshua <joshua@cnjmail.com>
2026-04-28 06:39:51 -04:00
f2d9b7f455 CUB-42: Show filament cost and usage in UI
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s
2026-04-27 21:34:47 -04:00
808d5f909d Merge pull request 'CUB-43: Add inventory dashboard summary' (#23) from agent/rex/CUB-43-inventory-dashboard-summary into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m38s
2026-04-27 21:29:25 -04:00
b7e61fab8a CUB-43: Add inventory dashboard summary component
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m19s
2026-04-27 21:25:56 -04:00
5ede6a8eb6 ci: re-trigger pipeline with working-directory fix 2026-04-27 21:25:56 -04:00
e56aa3ba39 CUB-43: add inventory dashboard summary component with FilamentService 2026-04-27 21:25:56 -04:00
f70495a85c Merge pull request 'CUB-9: Implement DELETE /filaments/{id}' (#30) from agent/dex/CUB-9-delete-filaments into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m10s
2026-04-27 21:19:24 -04:00
bb35ed1eab feat(CUB-9): Implement DELETE /filaments/{id}
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m7s
2026-04-27 21:16:56 -04:00
32 changed files with 4548 additions and 185 deletions

View File

@@ -1,6 +1,7 @@
using Extrudex.API.DTOs;
using Extrudex.API.DTOs.Filaments;
using Extrudex.Domain.Entities;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -17,16 +18,22 @@ namespace Extrudex.API.Controllers;
public class FilamentsController : ControllerBase
{
private readonly ExtrudexDbContext _dbContext;
private readonly ILowStockDetector _lowStockDetector;
private readonly ILogger<FilamentsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FilamentsController"/> class.
/// </summary>
/// <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>
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
public FilamentsController(
ExtrudexDbContext dbContext,
ILowStockDetector lowStockDetector,
ILogger<FilamentsController> logger)
{
_dbContext = dbContext;
_lowStockDetector = lowStockDetector;
_logger = logger;
}
@@ -95,7 +102,7 @@ public class FilamentsController : ControllerBase
.OrderByDescending(s => s.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(s => MapToFilamentResponse(s))
.Select(s => MapToFilamentResponse(s, _lowStockDetector))
.ToListAsync();
var response = new PagedResponse<FilamentResponse>
@@ -136,7 +143,7 @@ public class FilamentsController : ControllerBase
return NotFound(new { error = $"Filament with ID '{id}' not found." });
}
return Ok(MapToFilamentResponse(spool));
return Ok(MapToFilamentResponse(spool, _lowStockDetector));
}
/// <summary>
@@ -211,7 +218,7 @@ public class FilamentsController : ControllerBase
if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
var response = MapToFilamentResponse(entity);
var response = MapToFilamentResponse(entity, _lowStockDetector);
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
}
@@ -292,7 +299,97 @@ public class FilamentsController : ControllerBase
if (entity.MaterialModifierId.HasValue)
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
return Ok(MapToFilamentResponse(entity));
return Ok(MapToFilamentResponse(entity, _lowStockDetector));
}
/// <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>
/// Deletes a filament spool by its unique identifier.
/// If the spool has associated print jobs, the deletion is rejected with a 409 Conflict
/// to preserve COGS and print history — the caller should archive the spool instead.
/// Associated filament usage records are removed before the spool is deleted.
/// AMS slots referencing this spool will have their SpoolId set to null by the database.
/// </summary>
/// <param name="id">The unique identifier of the filament spool to delete.</param>
/// <returns>No content on successful deletion.</returns>
/// <response code="204">The filament spool was successfully deleted.</response>
/// <response code="404">If the filament spool with the given ID is not found.</response>
/// <response code="409">If the spool has associated print jobs and cannot be deleted.</response>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteFilament(Guid id)
{
_logger.LogInformation("Deleting filament {Id}", id);
var entity = await _dbContext.Spools.FindAsync(id);
if (entity is null)
{
_logger.LogWarning("Filament {Id} not found for deletion", id);
return NotFound(new { error = $"Filament with ID '{id}' not found." });
}
// Check for associated print jobs — these cannot be orphaned
var hasPrintJobs = await _dbContext.PrintJobs.AnyAsync(pj => pj.SpoolId == id);
if (hasPrintJobs)
{
_logger.LogWarning(
"Cannot delete filament {Id}: associated print jobs exist. Suggest archiving instead.", id);
return Conflict(new
{
error = $"Cannot delete filament '{id}' because it has associated print jobs. " +
"Archive the filament instead to preserve print history and COGS data."
});
}
// Remove associated filament usage records (usage tracking data for this spool)
var usageRecords = await _dbContext.FilamentUsages
.Where(fu => fu.SpoolId == id)
.ToListAsync();
if (usageRecords.Count > 0)
{
_logger.LogInformation(
"Removing {Count} filament usage records for spool {Id}",
usageRecords.Count, id);
_dbContext.FilamentUsages.RemoveRange(usageRecords);
}
_dbContext.Spools.Remove(entity);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Filament {Id} deleted successfully", id);
return NoContent();
}
// ── Mapping helper ─────────────────────────────────────────
@@ -301,10 +398,12 @@ public class FilamentsController : ControllerBase
/// Maps a Spool domain entity to a FilamentResponse DTO.
/// Denormalizes material names for display convenience.
/// Populates the QrCodeUrl for easy frontend access to the spool's QR code.
/// Calculates low-stock status and remaining weight percentage.
/// </summary>
/// <param name="s">The spool entity to map.</param>
/// <returns>A FilamentResponse DTO with denormalized material names and QR code URL.</returns>
private static FilamentResponse MapToFilamentResponse(Spool s) => new()
/// <param name="lowStockDetector">The low-stock detection service for computing alert flags.</param>
/// <returns>A FilamentResponse DTO with denormalized material names, QR code URL, and low-stock metadata.</returns>
private static FilamentResponse MapToFilamentResponse(Spool s, ILowStockDetector lowStockDetector) => new()
{
Id = s.Id,
MaterialBaseId = s.MaterialBaseId,
@@ -327,6 +426,8 @@ public class FilamentsController : ControllerBase
StorageLocation = s.StorageLocation,
CreatedAt = s.CreatedAt,
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)
};
}

View File

@@ -0,0 +1,117 @@
using Extrudex.API.DTOs.UsageLogs;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace Extrudex.API.Controllers;
/// <summary>
/// API controller for recording and querying filament usage logs.
/// Usage logs provide a fine-grained audit trail of filament consumption
/// from printer integrations or manual input.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class UsageLogsController : ControllerBase
{
private readonly IUsageLogService _usageLogService;
/// <summary>
/// Initializes a new instance of the <see cref="UsageLogsController"/> class.
/// </summary>
/// <param name="usageLogService">The usage log service for recording and querying usage.</param>
public UsageLogsController(IUsageLogService usageLogService)
{
_usageLogService = usageLogService;
}
/// <summary>
/// Records a new filament usage entry.
/// </summary>
/// <param name="request">The usage entry details.</param>
/// <returns>The created usage log entry.</returns>
[HttpPost]
[ProducesResponseType(typeof(UsageLogResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<UsageLogResponse>> Create([FromBody] CreateUsageLogRequest request)
{
if (!Enum.TryParse<DataSource>(request.DataSource, ignoreCase: true, out var dataSource))
{
return BadRequest($"Invalid data source: '{request.DataSource}'. Valid values: Mqtt, Moonraker, Manual.");
}
var entry = await _usageLogService.RecordUsageAsync(
spoolId: request.SpoolId,
gramsUsed: request.GramsUsed,
dataSource: dataSource,
printerId: request.PrinterId,
printJobId: request.PrintJobId,
mmExtruded: request.MmExtruded,
usageTimestamp: request.UsageTimestamp,
notes: request.Notes
);
return CreatedAtAction(
nameof(GetBySpool),
new { spoolId = entry.SpoolId },
MapToResponse(entry));
}
/// <summary>
/// Gets usage logs for a specific spool, ordered by most recent first.
/// </summary>
/// <param name="spoolId">The spool ID to filter by.</param>
/// <returns>A collection of usage log entries for the spool.</returns>
[HttpGet("spool/{spoolId:guid}")]
[ProducesResponseType(typeof(IEnumerable<UsageLogResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<UsageLogResponse>>> GetBySpool(Guid spoolId)
{
var logs = await _usageLogService.GetBySpoolAsync(spoolId);
return Ok(logs.Select(MapToResponse));
}
/// <summary>
/// Gets usage logs for a specific printer, ordered by most recent first.
/// </summary>
/// <param name="printerId">The printer ID to filter by.</param>
/// <returns>A collection of usage log entries for the printer.</returns>
[HttpGet("printer/{printerId:guid}")]
[ProducesResponseType(typeof(IEnumerable<UsageLogResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<UsageLogResponse>>> GetByPrinter(Guid printerId)
{
var logs = await _usageLogService.GetByPrinterAsync(printerId);
return Ok(logs.Select(MapToResponse));
}
/// <summary>
/// Gets usage logs for a specific print job, ordered by most recent first.
/// </summary>
/// <param name="printJobId">The print job ID to filter by.</param>
/// <returns>A collection of usage log entries for the print job.</returns>
[HttpGet("print-job/{printJobId:guid}")]
[ProducesResponseType(typeof(IEnumerable<UsageLogResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<UsageLogResponse>>> GetByPrintJob(Guid printJobId)
{
var logs = await _usageLogService.GetByPrintJobAsync(printJobId);
return Ok(logs.Select(MapToResponse));
}
/// <summary>
/// Maps a UsageLog domain entity to a UsageLogResponse DTO.
/// </summary>
private static UsageLogResponse MapToResponse(Domain.Entities.UsageLog log) => new()
{
Id = log.Id,
SpoolId = log.SpoolId,
PrinterId = log.PrinterId,
PrintJobId = log.PrintJobId,
GramsUsed = log.GramsUsed,
MmExtruded = log.MmExtruded,
UsageTimestamp = log.UsageTimestamp,
DataSource = log.DataSource.ToString(),
Notes = log.Notes,
CreatedAt = log.CreatedAt,
UpdatedAt = log.UpdatedAt
};
}

View File

@@ -76,6 +76,19 @@ public class FilamentResponse
/// Encodes a deep link to the spool's detail page.
/// </summary>
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 (0100).
/// Rounded to one decimal place. Returns 0 if total weight is zero.
/// </summary>
public decimal RemainingWeightPercent { get; set; }
}
/// <summary>

View File

@@ -0,0 +1,115 @@
using System.ComponentModel.DataAnnotations;
namespace Extrudex.API.DTOs.UsageLogs;
/// <summary>
/// Request DTO for recording a filament usage entry.
/// </summary>
public class CreateUsageLogRequest
{
/// <summary>
/// The ID of the spool that provided the filament.
/// </summary>
[Required]
public Guid SpoolId { get; set; }
/// <summary>
/// The number of grams of filament consumed.
/// </summary>
[Required]
[Range(0.01, double.MaxValue, ErrorMessage = "GramsUsed must be a positive value.")]
public decimal GramsUsed { get; set; }
/// <summary>
/// The source of the usage data (Mqtt, Moonraker, Manual).
/// </summary>
[Required]
public string DataSource { get; set; } = string.Empty;
/// <summary>
/// The ID of the printer that consumed the filament. Optional.
/// </summary>
public Guid? PrinterId { get; set; }
/// <summary>
/// The ID of the print job associated with this usage. Optional.
/// </summary>
public Guid? PrintJobId { get; set; }
/// <summary>
/// The number of millimeters of filament extruded. Optional.
/// </summary>
public decimal? MmExtruded { get; set; }
/// <summary>
/// When the usage occurred (UTC). Defaults to now if not specified.
/// </summary>
public DateTime? UsageTimestamp { get; set; }
/// <summary>
/// Optional notes about this usage entry.
/// </summary>
[MaxLength(2000)]
public string? Notes { get; set; }
}
/// <summary>
/// Response DTO for a usage log entry.
/// </summary>
public class UsageLogResponse
{
/// <summary>
/// Unique identifier for the usage log entry.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The spool that provided the filament.
/// </summary>
public Guid SpoolId { get; set; }
/// <summary>
/// The printer that consumed the filament, if applicable.
/// </summary>
public Guid? PrinterId { get; set; }
/// <summary>
/// The print job associated with this usage, if applicable.
/// </summary>
public Guid? PrintJobId { get; set; }
/// <summary>
/// Grams of filament consumed.
/// </summary>
public decimal GramsUsed { get; set; }
/// <summary>
/// Millimeters of filament extruded, if available.
/// </summary>
public decimal? MmExtruded { get; set; }
/// <summary>
/// When the usage occurred (UTC).
/// </summary>
public DateTime UsageTimestamp { get; set; }
/// <summary>
/// Source of the usage data (Mqtt, Moonraker, Manual).
/// </summary>
public string DataSource { get; set; } = string.Empty;
/// <summary>
/// Optional notes about this usage entry.
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// When the record was created (UTC).
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the record was last updated (UTC).
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,72 @@
using Extrudex.Domain.Base;
using Extrudex.Domain.Enums;
namespace Extrudex.Domain.Entities;
/// <summary>
/// Represents a single filament usage log entry. Records how much filament
/// was consumed, by which printer, at what time, and optionally linked to
/// a print job. This provides a fine-grained audit trail of filament consumption
/// independent of print job lifecycle.
/// </summary>
public class UsageLog : AuditableEntity
{
/// <summary>
/// Foreign key to the spool that provided the filament.
/// </summary>
public Guid SpoolId { get; set; }
/// <summary>
/// Navigation to the spool that provided the filament.
/// </summary>
public Spool Spool { get; set; } = null!;
/// <summary>
/// Foreign key to the printer that consumed the filament.
/// Nullable to support manual entries without a specific printer.
/// </summary>
public Guid? PrinterId { get; set; }
/// <summary>
/// Navigation to the printer that consumed the filament.
/// </summary>
public Printer? Printer { get; set; }
/// <summary>
/// Foreign key to the print job associated with this usage entry.
/// Nullable because usage can be logged before or without a print job.
/// </summary>
public Guid? PrintJobId { get; set; }
/// <summary>
/// Navigation to the print job associated with this usage entry.
/// </summary>
public PrintJob? PrintJob { get; set; }
/// <summary>
/// The number of grams of filament consumed in this usage event.
/// </summary>
public decimal GramsUsed { get; set; }
/// <summary>
/// The number of millimeters of filament extruded in this usage event.
/// Optional — may not be available for all data sources.
/// </summary>
public decimal? MmExtruded { get; set; }
/// <summary>
/// Timestamp when the usage occurred (UTC). This is the actual time of
/// consumption, which may differ from CreatedAt if the entry was recorded later.
/// </summary>
public DateTime UsageTimestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// The source of the usage data (which integration path provided it).
/// </summary>
public DataSource DataSource { get; set; } = DataSource.Manual;
/// <summary>
/// Optional notes about this usage entry.
/// </summary>
public string? Notes { get; set; }
}

View File

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

View File

@@ -0,0 +1,39 @@
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; }
}

View File

@@ -0,0 +1,57 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
namespace Extrudex.Domain.Interfaces;
/// <summary>
/// Service for recording filament usage entries. Writes to the usage_logs table
/// and provides query capabilities for usage history.
/// </summary>
public interface IUsageLogService
{
/// <summary>
/// Records a filament usage entry.
/// </summary>
/// <param name="spoolId">The spool that provided the filament.</param>
/// <param name="gramsUsed">Grams of filament consumed.</param>
/// <param name="dataSource">Where the data came from.</param>
/// <param name="printerId">Optional printer ID.</param>
/// <param name="printJobId">Optional print job ID.</param>
/// <param name="mmExtruded">Optional mm extruded.</param>
/// <param name="usageTimestamp">When the usage occurred (defaults to UTC now).</param>
/// <param name="notes">Optional notes.</param>
/// <returns>The created UsageLog entity.</returns>
Task<UsageLog> RecordUsageAsync(
Guid spoolId,
decimal gramsUsed,
DataSource dataSource,
Guid? printerId = null,
Guid? printJobId = null,
decimal? mmExtruded = null,
DateTime? usageTimestamp = null,
string? notes = null);
/// <summary>
/// Retrieves usage logs for a specific spool, ordered by usage timestamp descending.
/// </summary>
/// <param name="spoolId">The spool ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of usage logs for the spool.</returns>
Task<IEnumerable<UsageLog>> GetBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves usage logs for a specific printer, ordered by usage timestamp descending.
/// </summary>
/// <param name="printerId">The printer ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of usage logs for the printer.</returns>
Task<IEnumerable<UsageLog>> GetByPrinterAsync(Guid printerId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves usage logs for a specific print job, ordered by usage timestamp descending.
/// </summary>
/// <param name="printJobId">The print job ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A collection of usage logs for the print job.</returns>
Task<IEnumerable<UsageLog>> GetByPrintJobAsync(Guid printJobId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,91 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Extrudex.Infrastructure.Data.Configurations;
/// <summary>
/// EF Core configuration for the UsageLog entity.
/// Maps to the usage_logs table with snake_case columns and appropriate indexes.
/// </summary>
public class UsageLogConfiguration : BaseEntityConfiguration<UsageLog>
{
/// <inheritdoc/>
public override void Configure(EntityTypeBuilder<UsageLog> builder)
{
base.Configure(builder);
builder.Property(e => e.SpoolId)
.HasColumnName("spool_id")
.IsRequired();
builder.Property(e => e.PrinterId)
.HasColumnName("printer_id");
builder.Property(e => e.PrintJobId)
.HasColumnName("print_job_id");
builder.Property(e => e.GramsUsed)
.HasColumnName("grams_used")
.HasPrecision(10, 2)
.IsRequired();
builder.Property(e => e.MmExtruded)
.HasColumnName("mm_extruded")
.HasPrecision(12, 2);
builder.Property(e => e.UsageTimestamp)
.HasColumnName("usage_timestamp")
.IsRequired();
builder.Property(e => e.DataSource)
.HasColumnName("data_source")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(e => e.Notes)
.HasColumnName("notes")
.HasMaxLength(2000);
// Index on spool_id for querying usage by spool
builder.HasIndex(e => e.SpoolId)
.HasDatabaseName("ix_usage_logs_spool_id");
// Index on printer_id for querying usage by printer
builder.HasIndex(e => e.PrinterId)
.HasDatabaseName("ix_usage_logs_printer_id");
// Index on print_job_id for querying usage by print job
builder.HasIndex(e => e.PrintJobId)
.HasDatabaseName("ix_usage_logs_print_job_id");
// Index on usage_timestamp for chronological queries
builder.HasIndex(e => e.UsageTimestamp)
.HasDatabaseName("ix_usage_logs_usage_timestamp");
// Index on data_source for filtering by integration path
builder.HasIndex(e => e.DataSource)
.HasDatabaseName("ix_usage_logs_data_source");
// Relationships
builder.HasOne(e => e.Spool)
.WithMany()
.HasForeignKey(e => e.SpoolId)
.HasConstraintName("fk_usage_logs_spool")
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Printer)
.WithMany()
.HasForeignKey(e => e.PrinterId)
.HasConstraintName("fk_usage_logs_printer")
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(e => e.PrintJob)
.WithMany()
.HasForeignKey(e => e.PrintJobId)
.HasConstraintName("fk_usage_logs_print_job")
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -24,6 +24,7 @@ public class ExtrudexDbContext : DbContext
public DbSet<AmsSlot> AmsSlots => Set<AmsSlot>();
public DbSet<PrintJob> PrintJobs => Set<PrintJob>();
public DbSet<FilamentUsage> FilamentUsages => Set<FilamentUsage>();
public DbSet<UsageLog> UsageLogs => Set<UsageLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Extrudex.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsageLogTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "usage_logs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
spool_id = table.Column<Guid>(type: "uuid", nullable: false),
printer_id = table.Column<Guid>(type: "uuid", nullable: true),
print_job_id = table.Column<Guid>(type: "uuid", nullable: true),
grams_used = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
mm_extruded = table.Column<decimal>(type: "numeric(12,2)", precision: 12, scale: 2, nullable: true),
usage_timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
data_source = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
notes = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'")
},
constraints: table =>
{
table.PrimaryKey("PK_usage_logs", x => x.id);
table.ForeignKey(
name: "fk_usage_logs_print_job",
column: x => x.print_job_id,
principalTable: "print_jobs",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_usage_logs_printer",
column: x => x.printer_id,
principalTable: "printers",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_usage_logs_spool",
column: x => x.spool_id,
principalTable: "spools",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(6535), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(6535) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7016), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7016) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7027), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7028) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7034), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7035) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7042), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7042) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7049), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7049) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7291), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7292) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7453), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7453) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7461), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7461) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7468), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7468) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7474), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7474) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7480), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7481) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000007"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7487), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7487) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000008"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7493), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7493) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000009"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7500), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7500) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000010"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7507), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7507) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000011"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7513), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7513) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000012"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7519), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7520) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000013"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7526), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7526) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000014"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7532), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7532) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000015"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7538), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7539) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7690), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7690) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7838), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7838) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7846), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7846) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7853), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7853) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7859), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7859) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7865), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7866) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000007"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7872), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7872) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000008"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7878), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7879) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000009"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7885), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7885) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000010"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7891), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7891) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000011"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7898), new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7898) });
migrationBuilder.CreateIndex(
name: "ix_usage_logs_data_source",
table: "usage_logs",
column: "data_source");
migrationBuilder.CreateIndex(
name: "ix_usage_logs_print_job_id",
table: "usage_logs",
column: "print_job_id");
migrationBuilder.CreateIndex(
name: "ix_usage_logs_printer_id",
table: "usage_logs",
column: "printer_id");
migrationBuilder.CreateIndex(
name: "ix_usage_logs_spool_id",
table: "usage_logs",
column: "spool_id");
migrationBuilder.CreateIndex(
name: "ix_usage_logs_usage_timestamp",
table: "usage_logs",
column: "usage_timestamp");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "usage_logs");
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1096) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1620) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1630) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1638) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1645) });
migrationBuilder.UpdateData(
table: "material_bases",
keyColumn: "id",
keyValue: new Guid("10000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1651), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1652) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(1850) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2041) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2049) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2055), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2056) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2062) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2068) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000007"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2075) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000008"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2081) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000009"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2100) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000010"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2107) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000011"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2113) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000012"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2120) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000013"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2126) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000014"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2132), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2133) });
migrationBuilder.UpdateData(
table: "material_finishes",
keyColumn: "id",
keyValue: new Guid("20000000-0000-0000-0000-000000000015"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2139) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000001"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2304) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000002"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2463) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000003"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2471) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000004"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2477), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2478) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000005"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2484) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000006"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2490), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2491) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000007"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2497) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000008"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2503) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000009"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2510) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000010"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2516) });
migrationBuilder.UpdateData(
table: "material_modifiers",
keyColumn: "id",
keyValue: new Guid("30000000-0000-0000-0000-000000000011"),
columns: new[] { "created_at", "updated_at" },
values: new object[] { new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2522), new DateTime(2026, 4, 26, 13, 14, 18, 745, DateTimeKind.Utc).AddTicks(2523) });
}
}
}

View File

@@ -104,77 +104,6 @@ namespace Extrudex.Infrastructure.Data.Migrations
b.ToTable("ams_units", (string)null);
});
modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.Property<decimal>("GramsUsed")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)")
.HasColumnName("grams_used");
b.Property<decimal>("MmExtruded")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)")
.HasColumnName("mm_extruded");
b.Property<string>("Notes")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("notes");
b.Property<Guid>("PrintJobId")
.HasColumnType("uuid")
.HasColumnName("print_job_id");
b.Property<Guid>("PrinterId")
.HasColumnType("uuid")
.HasColumnName("printer_id");
b.Property<DateTime>("RecordedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.Property<Guid>("SpoolId")
.HasColumnType("uuid")
.HasColumnName("spool_id");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.HasKey("Id");
b.HasIndex("PrintJobId")
.HasDatabaseName("ix_filament_usages_print_job_id");
b.HasIndex("PrinterId")
.HasDatabaseName("ix_filament_usages_printer_id");
b.HasIndex("RecordedAt")
.HasDatabaseName("ix_filament_usages_recorded_at");
b.HasIndex("SpoolId")
.HasDatabaseName("ix_filament_usages_spool_id");
b.HasIndex("SpoolId", "RecordedAt")
.HasDatabaseName("ix_filament_usages_spool_id_recorded_at");
b.ToTable("filament_usages", (string)null);
});
modelBuilder.Entity("Extrudex.Domain.Entities.MaterialBase", b =>
{
b.Property<Guid>("Id")
@@ -216,50 +145,50 @@ namespace Extrudex.Infrastructure.Data.Migrations
new
{
Id = new Guid("10000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(6535),
DensityGperCm3 = 1.24m,
Name = "PLA",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9388)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(6535)
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7016),
DensityGperCm3 = 1.27m,
Name = "PETG",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9871)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7016)
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7027),
DensityGperCm3 = 1.04m,
Name = "ABS",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9881)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7028)
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7034),
DensityGperCm3 = 1.07m,
Name = "ASA",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9888)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7035)
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000005"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7042),
DensityGperCm3 = 1.21m,
Name = "TPU",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9895)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7042)
},
new
{
Id = new Guid("10000000-0000-0000-0000-000000000006"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9901),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7049),
DensityGperCm3 = 1.14m,
Name = "Nylon",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 291, DateTimeKind.Utc).AddTicks(9902)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7049)
});
});
@@ -303,122 +232,122 @@ namespace Extrudex.Infrastructure.Data.Migrations
new
{
Id = new Guid("20000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7291),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(90)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7292)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7453),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Matte",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(251)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7453)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7461),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Silk",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(259)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7461)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7468),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Glitter",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(266)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7468)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000005"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7474),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Marble",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(272)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7474)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000006"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7480),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Sparkle",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(278)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7481)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000007"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7487),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(285)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7487)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000008"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7493),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"),
Name = "Matte",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(291)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7493)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000009"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(297),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7500),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"),
Name = "Silk",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(298)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7500)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000010"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7507),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(304)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7507)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000011"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7513),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"),
Name = "Matte",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(310)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7513)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000012"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(316),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7519),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(317)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7520)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000013"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7526),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"),
Name = "Matte",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(323)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7526)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000014"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7532),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000005"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(329)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7532)
},
new
{
Id = new Guid("20000000-0000-0000-0000-000000000015"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7538),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"),
Name = "Basic",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(336)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7539)
});
});
@@ -462,90 +391,90 @@ namespace Extrudex.Infrastructure.Data.Migrations
new
{
Id = new Guid("30000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7690),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Carbon Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(482)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7690)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(805),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7838),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Glass Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(806)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7838)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7846),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Wood Fill",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(815)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7846)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7853),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000001"),
Name = "Glow-in-the-Dark",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(821)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7853)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000005"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7859),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"),
Name = "Carbon Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(828)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7859)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000006"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7865),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000002"),
Name = "Glass Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(834)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7866)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000007"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7872),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"),
Name = "Carbon Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(840)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7872)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000008"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7878),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000003"),
Name = "Glass Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(847)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7879)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000009"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7885),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000004"),
Name = "Carbon Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(853)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7885)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000010"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(859),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7891),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"),
Name = "Carbon Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(860)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7891)
},
new
{
Id = new Guid("30000000-0000-0000-0000-000000000011"),
CreatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866),
CreatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7898),
MaterialBaseId = new Guid("10000000-0000-0000-0000-000000000006"),
Name = "Glass Fiber",
UpdatedAt = new DateTime(2026, 4, 26, 18, 34, 33, 292, DateTimeKind.Utc).AddTicks(866)
UpdatedAt = new DateTime(2026, 4, 26, 18, 43, 28, 895, DateTimeKind.Utc).AddTicks(7898)
});
});
@@ -877,6 +806,81 @@ namespace Extrudex.Infrastructure.Data.Migrations
b.ToTable("spools", (string)null);
});
modelBuilder.Entity("Extrudex.Domain.Entities.UsageLog", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.Property<string>("DataSource")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("data_source");
b.Property<decimal>("GramsUsed")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)")
.HasColumnName("grams_used");
b.Property<decimal?>("MmExtruded")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)")
.HasColumnName("mm_extruded");
b.Property<string>("Notes")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("notes");
b.Property<Guid?>("PrintJobId")
.HasColumnType("uuid")
.HasColumnName("print_job_id");
b.Property<Guid?>("PrinterId")
.HasColumnType("uuid")
.HasColumnName("printer_id");
b.Property<Guid>("SpoolId")
.HasColumnType("uuid")
.HasColumnName("spool_id");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.Property<DateTime>("UsageTimestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("usage_timestamp");
b.HasKey("Id");
b.HasIndex("DataSource")
.HasDatabaseName("ix_usage_logs_data_source");
b.HasIndex("PrintJobId")
.HasDatabaseName("ix_usage_logs_print_job_id");
b.HasIndex("PrinterId")
.HasDatabaseName("ix_usage_logs_printer_id");
b.HasIndex("SpoolId")
.HasDatabaseName("ix_usage_logs_spool_id");
b.HasIndex("UsageTimestamp")
.HasDatabaseName("ix_usage_logs_usage_timestamp");
b.ToTable("usage_logs", (string)null);
});
modelBuilder.Entity("Extrudex.Domain.Entities.AmsSlot", b =>
{
b.HasOne("Extrudex.Domain.Entities.AmsUnit", "AmsUnit")
@@ -909,36 +913,6 @@ namespace Extrudex.Infrastructure.Data.Migrations
b.Navigation("Printer");
});
modelBuilder.Entity("Extrudex.Domain.Entities.FilamentUsage", b =>
{
b.HasOne("Extrudex.Domain.Entities.PrintJob", "PrintJob")
.WithMany("FilamentUsages")
.HasForeignKey("PrintJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_filament_usages_print_job");
b.HasOne("Extrudex.Domain.Entities.Printer", "Printer")
.WithMany("FilamentUsages")
.HasForeignKey("PrinterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_filament_usages_printer");
b.HasOne("Extrudex.Domain.Entities.Spool", "Spool")
.WithMany("FilamentUsages")
.HasForeignKey("SpoolId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_filament_usages_spool");
b.Navigation("PrintJob");
b.Navigation("Printer");
b.Navigation("Spool");
});
modelBuilder.Entity("Extrudex.Domain.Entities.MaterialFinish", b =>
{
b.HasOne("Extrudex.Domain.Entities.MaterialBase", "MaterialBase")
@@ -1013,6 +987,34 @@ namespace Extrudex.Infrastructure.Data.Migrations
b.Navigation("MaterialModifier");
});
modelBuilder.Entity("Extrudex.Domain.Entities.UsageLog", b =>
{
b.HasOne("Extrudex.Domain.Entities.PrintJob", "PrintJob")
.WithMany()
.HasForeignKey("PrintJobId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_usage_logs_print_job");
b.HasOne("Extrudex.Domain.Entities.Printer", "Printer")
.WithMany()
.HasForeignKey("PrinterId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_usage_logs_printer");
b.HasOne("Extrudex.Domain.Entities.Spool", "Spool")
.WithMany()
.HasForeignKey("SpoolId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_usage_logs_spool");
b.Navigation("PrintJob");
b.Navigation("Printer");
b.Navigation("Spool");
});
modelBuilder.Entity("Extrudex.Domain.Entities.AmsUnit", b =>
{
b.Navigation("Slots");
@@ -1037,17 +1039,10 @@ namespace Extrudex.Infrastructure.Data.Migrations
b.Navigation("Spools");
});
modelBuilder.Entity("Extrudex.Domain.Entities.PrintJob", b =>
{
b.Navigation("FilamentUsages");
});
modelBuilder.Entity("Extrudex.Domain.Entities.Printer", b =>
{
b.Navigation("AmsUnits");
b.Navigation("FilamentUsages");
b.Navigation("PrintJobs");
});
@@ -1055,8 +1050,6 @@ namespace Extrudex.Infrastructure.Data.Migrations
{
b.Navigation("AmsSlots");
b.Navigation("FilamentUsages");
b.Navigation("PrintJobs");
});
#pragma warning restore 612, 618

View File

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

View File

@@ -0,0 +1,95 @@
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);
}
}

View File

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

View File

@@ -0,0 +1,81 @@
using Extrudex.Domain.Entities;
using Extrudex.Domain.Enums;
using Extrudex.Domain.Interfaces;
using Extrudex.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Extrudex.Infrastructure.Services;
/// <summary>
/// Implementation of <see cref="IUsageLogService"/> that persists usage entries
/// to the usage_logs table via EF Core.
/// </summary>
public class UsageLogService : IUsageLogService
{
private readonly ExtrudexDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the <see cref="UsageLogService"/> class.
/// </summary>
/// <param name="dbContext">The EF Core database context for data persistence.</param>
public UsageLogService(ExtrudexDbContext dbContext)
{
_dbContext = dbContext;
}
/// <inheritdoc/>
public async Task<UsageLog> RecordUsageAsync(
Guid spoolId,
decimal gramsUsed,
DataSource dataSource,
Guid? printerId = null,
Guid? printJobId = null,
decimal? mmExtruded = null,
DateTime? usageTimestamp = null,
string? notes = null)
{
var entry = new UsageLog
{
SpoolId = spoolId,
GramsUsed = gramsUsed,
DataSource = dataSource,
PrinterId = printerId,
PrintJobId = printJobId,
MmExtruded = mmExtruded,
UsageTimestamp = usageTimestamp ?? DateTime.UtcNow,
Notes = notes
};
_dbContext.UsageLogs.Add(entry);
await _dbContext.SaveChangesAsync();
return entry;
}
/// <inheritdoc/>
public async Task<IEnumerable<UsageLog>> GetBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default)
{
return await _dbContext.UsageLogs
.Where(u => u.SpoolId == spoolId)
.OrderByDescending(u => u.UsageTimestamp)
.ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public async Task<IEnumerable<UsageLog>> GetByPrinterAsync(Guid printerId, CancellationToken cancellationToken = default)
{
return await _dbContext.UsageLogs
.Where(u => u.PrinterId == printerId)
.OrderByDescending(u => u.UsageTimestamp)
.ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public async Task<IEnumerable<UsageLog>> GetByPrintJobAsync(Guid printJobId, CancellationToken cancellationToken = default)
{
return await _dbContext.UsageLogs
.Where(u => u.PrintJobId == printJobId)
.OrderByDescending(u => u.UsageTimestamp)
.ToListAsync(cancellationToken);
}
}

View File

@@ -55,6 +55,20 @@ builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
// ── Cost Per Print Calculation ─────────────────────────────
builder.Services.AddScoped<ICostPerPrintService, CostPerPrintService>();
// ── Low Stock Detection ────────────────────────────────────
builder.Services.AddSingleton<ILowStockDetector, LowStockDetector>();
// ── Usage Logging ───────────────────────────────────────────
builder.Services.AddScoped<IUsageLogService, UsageLogService>();
// ── Filament Usage Service ──────────────────────────────────
builder.Services.AddScoped<IFilamentUsageService, FilamentUsageService>();
// ── Moonraker Usage Poller (Background Service) ─────────────
builder.Services.Configure<MoonrakerPollerOptions>(
builder.Configuration.GetSection("MoonrakerPoller"));
builder.Services.AddHostedService<MoonrakerUsagePoller>();
// ── FluentValidation ──────────────────────────────────────
// Registers all validators from the API assembly into DI.
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());

View File

@@ -20,5 +20,14 @@
"RequestTimeout": "00:00:15",
"Enabled": true,
"HistoryBatchSize": 25
},
"FilamentAlerts": {
"LowStockThresholdPercent": 20
},
"MoonrakerPoller": {
"Enabled": true,
"PollInterval": "00:00:30",
"RequestTimeout": "00:00:10"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,35 @@
</td>
</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">&mdash;</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 -->
<ng-container matColumnDef="stockLevel">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>

View File

@@ -55,7 +55,7 @@ $color-inactive: #94a3b8; // Gray — inactive spool
// Table styling
.filament-table {
width: 100%;
min-width: 700px;
min-width: 900px;
th {
font-weight: 600;
@@ -132,6 +132,48 @@ $color-inactive: #94a3b8; // Gray — inactive spool
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-cell {
display: flex;

View File

@@ -30,6 +30,8 @@ export type FilamentColumn =
| 'brand'
| 'serial'
| 'remaining'
| 'cost'
| 'usage'
| 'stockLevel'
| 'status';
@@ -70,6 +72,8 @@ export class FilamentTableComponent implements OnInit {
'brand',
'serial',
'remaining',
'cost',
'usage',
'stockLevel',
'status',
]);
@@ -143,6 +147,18 @@ export class FilamentTableComponent implements OnInit {
getRemainingPercent(b),
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':
return compare(
stockLevelOrder(classifyStockLevel(a)),
@@ -243,6 +259,29 @@ export class FilamentTableComponent implements OnInit {
}
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 */

View File

@@ -87,6 +87,43 @@
</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 -->
<div class="summary-item metric-card stock-bar-card"
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"

View File

@@ -88,6 +88,37 @@ export class InventorySummaryComponent implements OnInit, OnDestroy {
.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 */
readonly totalRemainingGrams = computed(() =>
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0)
@@ -169,8 +200,8 @@ export class InventorySummaryComponent implements OnInit, OnDestroy {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
}

View File

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

View File

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

View File

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

View File

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