initial commit
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
314
backend/API/Controllers/FilamentsController.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using Extrudex.API.DTOs;
|
||||
using Extrudex.API.DTOs.Filaments;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing filament spools — the core inventory unit of Extrudex.
|
||||
/// Exposes spool data under the user-facing "filaments" route while mapping
|
||||
/// to the Spool domain entity internally.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/filaments")]
|
||||
public class FilamentsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
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="logger">The logger for diagnostic output.</param>
|
||||
public FilamentsController(ExtrudexDbContext dbContext, ILogger<FilamentsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a paginated list of filament spools with optional filtering by material,
|
||||
/// brand, and active status. Results are ordered by creation date (newest first)
|
||||
/// and include denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="query">Optional query parameters for pagination and filtering.</param>
|
||||
/// <returns>A paginated list of filament spools matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the paginated list of filament spools.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<FilamentResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<FilamentResponse>>> GetFilaments(
|
||||
[FromQuery] FilamentQueryParameters query)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Getting filaments: pageNumber={PageNumber}, pageSize={PageSize}, " +
|
||||
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, " +
|
||||
"materialModifierId={MaterialModifierId}, brand={Brand}, isActive={IsActive}",
|
||||
query.PageNumber, query.PageSize, query.MaterialBaseId,
|
||||
query.MaterialFinishId, query.MaterialModifierId, query.Brand, query.IsActive);
|
||||
|
||||
// Clamp pagination values
|
||||
var pageNumber = Math.Max(1, query.PageNumber);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var spoolQuery = _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (query.MaterialBaseId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value);
|
||||
|
||||
if (query.MaterialFinishId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value);
|
||||
|
||||
if (query.MaterialModifierId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialModifierId == query.MaterialModifierId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Brand))
|
||||
spoolQuery = spoolQuery.Where(s =>
|
||||
s.Brand.ToLower().Contains(query.Brand.ToLower()));
|
||||
|
||||
if (query.IsActive.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
|
||||
|
||||
var totalCount = await spoolQuery.CountAsync();
|
||||
|
||||
var items = await spoolQuery
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => MapToFilamentResponse(s))
|
||||
.ToListAsync();
|
||||
|
||||
var response = new PagedResponse<FilamentResponse>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific filament spool by its unique identifier.
|
||||
/// Includes denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the filament spool.</param>
|
||||
/// <returns>The filament spool details.</returns>
|
||||
/// <response code="200">Returns the filament spool details.</response>
|
||||
/// <response code="404">If the filament spool with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<FilamentResponse>> GetFilament(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting filament {Id}", id);
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (spool is null)
|
||||
{
|
||||
_logger.LogWarning("Filament {Id} not found", id);
|
||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToFilamentResponse(spool));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new filament spool. Validates that all foreign keys reference
|
||||
/// existing material entities and that the SpoolSerial is unique.
|
||||
/// WeightRemainingGrams must not exceed WeightTotalGrams.
|
||||
/// </summary>
|
||||
/// <param name="request">The filament creation request with all required fields.</param>
|
||||
/// <returns>The newly created filament spool with generated ID and timestamps.</returns>
|
||||
/// <response code="201">Returns the created filament spool with location header.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FilamentResponse>> CreateFilament(
|
||||
[FromBody] CreateFilamentRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating filament: {Serial} - {Brand} {Color}",
|
||||
request.SpoolSerial, request.Brand, request.ColorName);
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Validate serial uniqueness
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
var entity = new Spool
|
||||
{
|
||||
MaterialBaseId = request.MaterialBaseId,
|
||||
MaterialFinishId = request.MaterialFinishId,
|
||||
MaterialModifierId = request.MaterialModifierId,
|
||||
Brand = request.Brand,
|
||||
ColorName = request.ColorName,
|
||||
ColorHex = request.ColorHex,
|
||||
WeightTotalGrams = request.WeightTotalGrams,
|
||||
WeightRemainingGrams = request.WeightRemainingGrams,
|
||||
FilamentDiameterMm = request.FilamentDiameterMm,
|
||||
SpoolSerial = request.SpoolSerial,
|
||||
PurchasePrice = request.PurchasePrice,
|
||||
PurchaseDate = request.PurchaseDate,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_dbContext.Spools.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
var response = MapToFilamentResponse(entity);
|
||||
return CreatedAtAction(nameof(GetFilament), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing filament spool. Validates foreign keys and serial uniqueness
|
||||
/// (excluding the current spool from the uniqueness check).
|
||||
/// WeightRemainingGrams must not exceed WeightTotalGrams.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the filament spool to update.</param>
|
||||
/// <param name="request">The filament update request with all required fields.</param>
|
||||
/// <returns>The updated filament spool with current timestamps.</returns>
|
||||
/// <response code="200">Returns the updated filament spool.</response>
|
||||
/// <response code="404">If the filament spool with the given ID is not found.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(FilamentResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FilamentResponse>> UpdateFilament(
|
||||
Guid id, [FromBody] UpdateFilamentRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating filament {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Filament {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Filament with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Check serial uniqueness (excluding current spool)
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Filament with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
entity.MaterialFinishId = request.MaterialFinishId;
|
||||
entity.MaterialModifierId = request.MaterialModifierId;
|
||||
entity.Brand = request.Brand;
|
||||
entity.ColorName = request.ColorName;
|
||||
entity.ColorHex = request.ColorHex;
|
||||
entity.WeightTotalGrams = request.WeightTotalGrams;
|
||||
entity.WeightRemainingGrams = request.WeightRemainingGrams;
|
||||
entity.FilamentDiameterMm = request.FilamentDiameterMm;
|
||||
entity.SpoolSerial = request.SpoolSerial;
|
||||
entity.PurchasePrice = request.PurchasePrice;
|
||||
entity.PurchaseDate = request.PurchaseDate;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
return Ok(MapToFilamentResponse(entity));
|
||||
}
|
||||
|
||||
// ── Mapping helper ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </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()
|
||||
{
|
||||
Id = s.Id,
|
||||
MaterialBaseId = s.MaterialBaseId,
|
||||
MaterialBaseName = s.MaterialBase?.Name ?? string.Empty,
|
||||
MaterialFinishId = s.MaterialFinishId,
|
||||
MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty,
|
||||
MaterialModifierId = s.MaterialModifierId,
|
||||
MaterialModifierName = s.MaterialModifier?.Name,
|
||||
Brand = s.Brand,
|
||||
ColorName = s.ColorName,
|
||||
ColorHex = s.ColorHex,
|
||||
WeightTotalGrams = s.WeightTotalGrams,
|
||||
WeightRemainingGrams = s.WeightRemainingGrams,
|
||||
FilamentDiameterMm = s.FilamentDiameterMm,
|
||||
SpoolSerial = s.SpoolSerial,
|
||||
PurchasePrice = s.PurchasePrice,
|
||||
PurchaseDate = s.PurchaseDate,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt,
|
||||
QrCodeUrl = $"/api/qr/spool/{s.Id}"
|
||||
};
|
||||
}
|
||||
158
backend/API/Controllers/MaterialBasesController.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material bases — the core polymer/material type
|
||||
/// (e.g., PLA, PETG, ABS). Enforces consistent material naming across all spools.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-bases")]
|
||||
public class MaterialBasesController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialBasesController> _logger;
|
||||
|
||||
public MaterialBasesController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialBasesController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material bases ordered by name.
|
||||
/// </summary>
|
||||
/// <returns>A list of all material bases with their densities.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
|
||||
{
|
||||
_logger.LogDebug("Getting all material bases");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => MapToResponse(mb))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material base by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base.</param>
|
||||
/// <returns>The material base details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material base {Id}", id);
|
||||
|
||||
var mb = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (mb is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mb));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material base.
|
||||
/// </summary>
|
||||
/// <param name="request">The material base creation request.</param>
|
||||
/// <returns>The created material base.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
|
||||
[FromBody] CreateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material base: {Name}", request.Name);
|
||||
|
||||
var entity = new MaterialBase
|
||||
{
|
||||
Name = request.Name,
|
||||
DensityGperCm3 = request.DensityGperCm3
|
||||
};
|
||||
|
||||
_dbContext.MaterialBases.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to update.</param>
|
||||
/// <param name="request">The material base update request.</param>
|
||||
/// <returns>The updated material base.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
|
||||
Guid id, [FromBody] UpdateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.DensityGperCm3 = request.DensityGperCm3;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialBases.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialBaseResponse MapToResponse(MaterialBase mb) => new()
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
};
|
||||
}
|
||||
191
backend/API/Controllers/MaterialFinishesController.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material finishes — the surface finish descriptor
|
||||
/// for a material. This is REQUIRED on every spool record. Default: "Basic".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-finishes")]
|
||||
public class MaterialFinishesController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialFinishesController> _logger;
|
||||
|
||||
public MaterialFinishesController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialFinishesController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material finishes, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
|
||||
/// <returns>A list of material finishes ordered by base material name, then finish name.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
|
||||
|
||||
var finishes = await query
|
||||
.OrderBy(mf => mf.MaterialBase.Name)
|
||||
.ThenBy(mf => mf.Name)
|
||||
.Select(mf => MapToResponse(mf))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(finishes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material finish by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish.</param>
|
||||
/// <returns>The material finish details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material finish {Id}", id);
|
||||
|
||||
var mf = await _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.FirstOrDefaultAsync(mf => mf.Id == id);
|
||||
|
||||
if (mf is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mf));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material finish.
|
||||
/// </summary>
|
||||
/// <param name="request">The material finish creation request.</param>
|
||||
/// <returns>The created material finish.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
|
||||
[FromBody] CreateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialFinish
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialFinishes.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to update.</param>
|
||||
/// <param name="request">The material finish update request.</param>
|
||||
/// <returns>The updated material finish.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
|
||||
Guid id, [FromBody] UpdateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialFinishes.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialFinishResponse MapToResponse(MaterialFinish mf) => new()
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = mf.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
};
|
||||
}
|
||||
533
backend/API/Controllers/MaterialLookupsController.cs
Normal file
@@ -0,0 +1,533 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.API.Validators;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for querying material metadata — bases, finishes, and modifiers.
|
||||
/// Provides lookup/reference data for the spool inventory system.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/materials")]
|
||||
public class MaterialLookupsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialLookupsController> _logger;
|
||||
|
||||
public MaterialLookupsController(ExtrudexDbContext dbContext, ILogger<MaterialLookupsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── MaterialBase ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material bases (PLA, PETG, ABS, etc.).
|
||||
/// </summary>
|
||||
/// <returns>A list of all material bases with their densities.</returns>
|
||||
[HttpGet("bases")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialBases()
|
||||
{
|
||||
_logger.LogDebug("Getting all material bases");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => new MaterialBaseResponse
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material base by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base.</param>
|
||||
/// <returns>The material base details.</returns>
|
||||
[HttpGet("bases/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> GetMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material base {Id}", id);
|
||||
|
||||
var mb = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (mb is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialBaseResponse(mb));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material base.
|
||||
/// </summary>
|
||||
/// <param name="request">The material base creation request.</param>
|
||||
/// <returns>The created material base.</returns>
|
||||
[HttpPost("bases")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> CreateMaterialBase(
|
||||
[FromBody] CreateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material base: {Name}", request.Name);
|
||||
|
||||
var entity = new MaterialBase
|
||||
{
|
||||
Name = request.Name,
|
||||
DensityGperCm3 = request.DensityGperCm3
|
||||
};
|
||||
|
||||
_dbContext.MaterialBases.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialBaseResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialBase), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to update.</param>
|
||||
/// <param name="request">The material base update request.</param>
|
||||
/// <returns>The updated material base.</returns>
|
||||
[HttpPut("bases/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialBaseResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialBaseResponse>> UpdateMaterialBase(
|
||||
Guid id, [FromBody] UpdateMaterialBaseRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.DensityGperCm3 = request.DensityGperCm3;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialBaseResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material base.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the material base to delete.</param>
|
||||
[HttpDelete("bases/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialBase(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material base {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialBases.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material base {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material base with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialBases.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── MaterialFinish ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material finishes, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return finishes for this material base only.</param>
|
||||
/// <returns>A list of material finishes.</returns>
|
||||
[HttpGet("finishes")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialFinishResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialFinishResponse>>> GetMaterialFinishes(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material finishes, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
{
|
||||
query = query.Where(mf => mf.MaterialBaseId == materialBaseId.Value);
|
||||
}
|
||||
|
||||
var finishes = await query
|
||||
.OrderBy(mf => mf.MaterialBase.Name)
|
||||
.ThenBy(mf => mf.Name)
|
||||
.Select(mf => new MaterialFinishResponse
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = mf.MaterialBase.Name,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(finishes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material finish by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish.</param>
|
||||
/// <returns>The material finish details.</returns>
|
||||
[HttpGet("finishes/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> GetMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material finish {Id}", id);
|
||||
|
||||
var mf = await _dbContext.MaterialFinishes
|
||||
.Include(mf => mf.MaterialBase)
|
||||
.FirstOrDefaultAsync(mf => mf.Id == id);
|
||||
|
||||
if (mf is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialFinishResponse(mf));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material finish.
|
||||
/// </summary>
|
||||
/// <param name="request">The material finish creation request.</param>
|
||||
/// <returns>The created material finish.</returns>
|
||||
[HttpPost("finishes")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> CreateMaterialFinish(
|
||||
[FromBody] CreateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material finish: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialFinish
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialFinishes.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialFinishResponse(entity, materialBase.Name);
|
||||
return CreatedAtAction(nameof(GetMaterialFinish), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to update.</param>
|
||||
/// <param name="request">The material finish update request.</param>
|
||||
/// <returns>The updated material finish.</returns>
|
||||
[HttpPut("finishes/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialFinishResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialFinishResponse>> UpdateMaterialFinish(
|
||||
Guid id, [FromBody] UpdateMaterialFinishRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialFinishResponse(entity, materialBase.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material finish.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the finish to delete.</param>
|
||||
[HttpDelete("finishes/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialFinish(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material finish {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialFinishes.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material finish {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material finish with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialFinishes.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── MaterialModifier ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material modifiers, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
|
||||
/// <returns>A list of material modifiers.</returns>
|
||||
[HttpGet("modifiers")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
{
|
||||
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
|
||||
}
|
||||
|
||||
var modifiers = await query
|
||||
.OrderBy(mm => mm.MaterialBase.Name)
|
||||
.ThenBy(mm => mm.Name)
|
||||
.Select(mm => new MaterialModifierResponse
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = mm.MaterialBase.Name,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(modifiers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material modifier by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier.</param>
|
||||
/// <returns>The material modifier details.</returns>
|
||||
[HttpGet("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifier {Id}", id);
|
||||
|
||||
var mm = await _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.FirstOrDefaultAsync(mm => mm.Id == id);
|
||||
|
||||
if (mm is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToMaterialModifierResponse(mm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material modifier.
|
||||
/// </summary>
|
||||
/// <param name="request">The material modifier creation request.</param>
|
||||
/// <returns>The created material modifier.</returns>
|
||||
[HttpPost("modifiers")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
|
||||
[FromBody] CreateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialModifier
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialModifiers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToMaterialModifierResponse(entity, materialBase.Name);
|
||||
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to update.</param>
|
||||
/// <param name="request">The material modifier update request.</param>
|
||||
/// <returns>The updated material modifier.</returns>
|
||||
[HttpPut("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
|
||||
Guid id, [FromBody] UpdateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToMaterialModifierResponse(entity, materialBase.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to delete.</param>
|
||||
[HttpDelete("modifiers/{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialModifiers.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Density convenience endpoint ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material density data (shorthand for bases with density info).
|
||||
/// </summary>
|
||||
/// <returns>A list of material bases with their density values.</returns>
|
||||
[HttpGet("densities")]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialBaseResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialBaseResponse>>> GetMaterialDensities()
|
||||
{
|
||||
_logger.LogDebug("Getting material densities");
|
||||
|
||||
var bases = await _dbContext.MaterialBases
|
||||
.OrderBy(mb => mb.Name)
|
||||
.Select(mb => new MaterialBaseResponse
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(bases);
|
||||
}
|
||||
|
||||
// ── Mapping helpers ───────────────────────────────────────
|
||||
|
||||
private static MaterialBaseResponse MapToMaterialBaseResponse(MaterialBase mb) => new()
|
||||
{
|
||||
Id = mb.Id,
|
||||
Name = mb.Name,
|
||||
DensityGperCm3 = mb.DensityGperCm3,
|
||||
CreatedAt = mb.CreatedAt,
|
||||
UpdatedAt = mb.UpdatedAt
|
||||
};
|
||||
|
||||
private static MaterialFinishResponse MapToMaterialFinishResponse(
|
||||
MaterialFinish mf, string? materialBaseName = null) => new()
|
||||
{
|
||||
Id = mf.Id,
|
||||
Name = mf.Name,
|
||||
MaterialBaseId = mf.MaterialBaseId,
|
||||
MaterialBaseName = materialBaseName ?? mf.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mf.CreatedAt,
|
||||
UpdatedAt = mf.UpdatedAt
|
||||
};
|
||||
|
||||
private static MaterialModifierResponse MapToMaterialModifierResponse(
|
||||
MaterialModifier mm, string? materialBaseName = null) => new()
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = materialBaseName ?? mm.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
};
|
||||
}
|
||||
192
backend/API/Controllers/MaterialModifiersController.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing material modifiers — optional additives or
|
||||
/// fillers for a material (e.g., "Carbon Fiber", "Wood Fill", "Glow-in-the-Dark").
|
||||
/// Not every spool has a modifier.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/material-modifiers")]
|
||||
public class MaterialModifiersController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<MaterialModifiersController> _logger;
|
||||
|
||||
public MaterialModifiersController(
|
||||
ExtrudexDbContext dbContext,
|
||||
ILogger<MaterialModifiersController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all material modifiers, optionally filtered by material base.
|
||||
/// </summary>
|
||||
/// <param name="materialBaseId">Optional filter: return modifiers for this material base only.</param>
|
||||
/// <returns>A list of material modifiers ordered by base material name, then modifier name.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<MaterialModifierResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<MaterialModifierResponse>>> GetMaterialModifiers(
|
||||
[FromQuery] Guid? materialBaseId)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifiers, filter: {MaterialBaseId}", materialBaseId);
|
||||
|
||||
var query = _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.AsQueryable();
|
||||
|
||||
if (materialBaseId.HasValue)
|
||||
query = query.Where(mm => mm.MaterialBaseId == materialBaseId.Value);
|
||||
|
||||
var modifiers = await query
|
||||
.OrderBy(mm => mm.MaterialBase.Name)
|
||||
.ThenBy(mm => mm.Name)
|
||||
.Select(mm => MapToResponse(mm))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(modifiers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific material modifier by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier.</param>
|
||||
/// <returns>The material modifier details.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> GetMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting material modifier {Id}", id);
|
||||
|
||||
var mm = await _dbContext.MaterialModifiers
|
||||
.Include(mm => mm.MaterialBase)
|
||||
.FirstOrDefaultAsync(mm => mm.Id == id);
|
||||
|
||||
if (mm is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(mm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new material modifier.
|
||||
/// </summary>
|
||||
/// <param name="request">The material modifier creation request.</param>
|
||||
/// <returns>The created material modifier.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> CreateMaterialModifier(
|
||||
[FromBody] CreateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating material modifier: {Name} for base {MaterialBaseId}",
|
||||
request.Name, request.MaterialBaseId);
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
var entity = new MaterialModifier
|
||||
{
|
||||
Name = request.Name,
|
||||
MaterialBaseId = request.MaterialBaseId
|
||||
};
|
||||
|
||||
_dbContext.MaterialModifiers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetMaterialModifier), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to update.</param>
|
||||
/// <param name="request">The material modifier update request.</param>
|
||||
/// <returns>The updated material modifier.</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(MaterialModifierResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<MaterialModifierResponse>> UpdateMaterialModifier(
|
||||
Guid id, [FromBody] UpdateMaterialModifierRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
{
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
}
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload with navigation for response mapping
|
||||
await _dbContext.Entry(entity).Reference(e => e.MaterialBase).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a material modifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the modifier to delete.</param>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMaterialModifier(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Deleting material modifier {Id}", id);
|
||||
|
||||
var entity = await _dbContext.MaterialModifiers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Material modifier {Id} not found for deletion", id);
|
||||
return NotFound(new { error = $"Material modifier with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
_dbContext.MaterialModifiers.Remove(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ──────────────────────────────────────────
|
||||
|
||||
private static MaterialModifierResponse MapToResponse(MaterialModifier mm) => new()
|
||||
{
|
||||
Id = mm.Id,
|
||||
Name = mm.Name,
|
||||
MaterialBaseId = mm.MaterialBaseId,
|
||||
MaterialBaseName = mm.MaterialBase?.Name ?? string.Empty,
|
||||
CreatedAt = mm.CreatedAt,
|
||||
UpdatedAt = mm.UpdatedAt
|
||||
};
|
||||
}
|
||||
512
backend/API/Controllers/PrintJobsController.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
using Extrudex.API.DTOs;
|
||||
using Extrudex.API.DTOs.PrintJobs;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing print jobs — the core operational record of Extrudex.
|
||||
/// Supports full CRUD with pagination, filtering by printer/spool/status,
|
||||
/// status transition validation, gram derivation from filament parameters,
|
||||
/// and soft-delete (sets Status to Cancelled).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/printjobs")]
|
||||
public class PrintJobsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<PrintJobsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PrintJobsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public PrintJobsController(ExtrudexDbContext dbContext, ILogger<PrintJobsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── GET /api/printjobs ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets a paginated list of print jobs with optional filtering by
|
||||
/// printer, spool, and status. Results are ordered by creation date
|
||||
/// (newest first) and include denormalized printer name and spool serial.
|
||||
/// </summary>
|
||||
/// <param name="query">Optional query parameters for pagination and filtering.</param>
|
||||
/// <returns>A paginated list of print jobs matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the paginated list of print jobs.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<PrintJobResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<PrintJobResponse>>> GetPrintJobs(
|
||||
[FromQuery] PrintJobQueryParameters query)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Getting print jobs: pageNumber={PageNumber}, pageSize={PageSize}, " +
|
||||
"printerId={PrinterId}, spoolId={SpoolId}, status={Status}",
|
||||
query.PageNumber, query.PageSize, query.PrinterId, query.SpoolId, query.Status);
|
||||
|
||||
// Clamp pagination values
|
||||
var pageNumber = Math.Max(1, query.PageNumber);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var jobQuery = _dbContext.PrintJobs
|
||||
.Include(j => j.Printer)
|
||||
.Include(j => j.Spool)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (query.PrinterId.HasValue)
|
||||
jobQuery = jobQuery.Where(j => j.PrinterId == query.PrinterId.Value);
|
||||
|
||||
if (query.SpoolId.HasValue)
|
||||
jobQuery = jobQuery.Where(j => j.SpoolId == query.SpoolId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status) &&
|
||||
Enum.TryParse<JobStatus>(query.Status, ignoreCase: true, out var statusFilter))
|
||||
{
|
||||
jobQuery = jobQuery.Where(j => j.Status == statusFilter);
|
||||
}
|
||||
|
||||
var totalCount = await jobQuery.CountAsync();
|
||||
|
||||
var items = await jobQuery
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(j => MapToResponse(j))
|
||||
.ToListAsync();
|
||||
|
||||
var response = new PagedResponse<PrintJobResponse>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// ── GET /api/printjobs/{id} ───────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific print job by its unique identifier.
|
||||
/// Includes denormalized printer name and spool serial for display.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the print job.</param>
|
||||
/// <returns>The print job details.</returns>
|
||||
/// <response code="200">Returns the print job details.</response>
|
||||
/// <response code="404">If the print job with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PrintJobResponse>> GetPrintJob(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting print job {Id}", id);
|
||||
|
||||
var job = await _dbContext.PrintJobs
|
||||
.Include(j => j.Printer)
|
||||
.Include(j => j.Spool)
|
||||
.FirstOrDefaultAsync(j => j.Id == id);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
_logger.LogWarning("Print job {Id} not found", id);
|
||||
return NotFound(new { error = $"Print job with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToResponse(job));
|
||||
}
|
||||
|
||||
// ── POST /api/printjobs ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new print job record. Validates that PrinterId and SpoolId
|
||||
/// reference existing entities. When AutoDeriveGrams is true, the gram
|
||||
/// derivation formula is computed from the spool's material data:
|
||||
/// grams = mm_extruded × π × (diameter/2)² × (density/1000).
|
||||
/// </summary>
|
||||
/// <param name="request">The print job creation request with all required fields.</param>
|
||||
/// <returns>The newly created print job with generated ID and timestamps.</returns>
|
||||
/// <response code="201">Returns the created print job with location header.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrintJobResponse>> CreatePrintJob([FromBody] CreatePrintJobRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating print job: {PrintName} on printer {PrinterId} with spool {SpoolId}",
|
||||
request.PrintName, request.PrinterId, request.SpoolId);
|
||||
|
||||
// Validate foreign keys exist
|
||||
var printer = await _dbContext.Printers.FindAsync(request.PrinterId);
|
||||
if (printer is null)
|
||||
return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." });
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.FirstOrDefaultAsync(s => s.Id == request.SpoolId);
|
||||
if (spool is null)
|
||||
return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." });
|
||||
|
||||
// Resolve gram derivation parameters
|
||||
decimal filamentDiameter = request.FilamentDiameterAtPrintMm;
|
||||
decimal materialDensity = request.MaterialDensityAtPrint;
|
||||
decimal gramsDerived = request.GramsDerived;
|
||||
|
||||
if (request.AutoDeriveGrams)
|
||||
{
|
||||
filamentDiameter = spool.FilamentDiameterMm;
|
||||
materialDensity = spool.MaterialBase.DensityGperCm3;
|
||||
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
|
||||
}
|
||||
else if (request.MmExtruded > 0 && gramsDerived == 0 && materialDensity > 0)
|
||||
{
|
||||
// Auto-derive if mm_extruded is provided but gramsDerived is zero
|
||||
// and we have enough info to compute it
|
||||
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
|
||||
}
|
||||
|
||||
// Parse data source enum
|
||||
if (!Enum.TryParse<DataSource>(request.DataSource, ignoreCase: true, out var dataSource))
|
||||
return BadRequest(new { error = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'." });
|
||||
|
||||
var entity = new PrintJob
|
||||
{
|
||||
PrinterId = request.PrinterId,
|
||||
SpoolId = request.SpoolId,
|
||||
PrintName = request.PrintName,
|
||||
GcodeFilePath = request.GcodeFilePath,
|
||||
MmExtruded = request.MmExtruded,
|
||||
GramsDerived = gramsDerived,
|
||||
CostPerPrint = request.CostPerPrint,
|
||||
StartedAt = request.StartedAt,
|
||||
Status = JobStatus.Queued,
|
||||
DataSource = dataSource,
|
||||
FilamentDiameterAtPrintMm = filamentDiameter,
|
||||
MaterialDensityAtPrint = materialDensity,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
_dbContext.PrintJobs.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
|
||||
|
||||
var response = MapToResponse(entity);
|
||||
return CreatedAtAction(nameof(GetPrintJob), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
// ── PUT /api/printjobs/{id} ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing print job. Validates that PrinterId and SpoolId
|
||||
/// reference existing entities. When AutoDeriveGrams is true, gram
|
||||
/// derivation is recomputed from the spool's current material data.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the print job to update.</param>
|
||||
/// <param name="request">The print job update request with all required fields.</param>
|
||||
/// <returns>The updated print job with current timestamps.</returns>
|
||||
/// <response code="200">Returns the updated print job.</response>
|
||||
/// <response code="404">If the print job with the given ID is not found.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrintJobResponse>> UpdatePrintJob(
|
||||
Guid id, [FromBody] UpdatePrintJobRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating print job {Id}", id);
|
||||
|
||||
var entity = await _dbContext.PrintJobs.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Print job {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Print job with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Validate foreign keys exist
|
||||
var printer = await _dbContext.Printers.FindAsync(request.PrinterId);
|
||||
if (printer is null)
|
||||
return BadRequest(new { error = $"Printer with ID '{request.PrinterId}' not found." });
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.FirstOrDefaultAsync(s => s.Id == request.SpoolId);
|
||||
if (spool is null)
|
||||
return BadRequest(new { error = $"Spool with ID '{request.SpoolId}' not found." });
|
||||
|
||||
// Resolve gram derivation
|
||||
decimal gramsDerived = request.GramsDerived;
|
||||
decimal filamentDiameter = entity.FilamentDiameterAtPrintMm;
|
||||
decimal materialDensity = entity.MaterialDensityAtPrint;
|
||||
|
||||
if (request.AutoDeriveGrams)
|
||||
{
|
||||
filamentDiameter = spool.FilamentDiameterMm;
|
||||
materialDensity = spool.MaterialBase.DensityGperCm3;
|
||||
gramsDerived = DeriveGrams(request.MmExtruded, filamentDiameter, materialDensity);
|
||||
}
|
||||
|
||||
// Update entity fields
|
||||
entity.PrinterId = request.PrinterId;
|
||||
entity.SpoolId = request.SpoolId;
|
||||
entity.PrintName = request.PrintName;
|
||||
entity.GcodeFilePath = request.GcodeFilePath;
|
||||
entity.MmExtruded = request.MmExtruded;
|
||||
entity.GramsDerived = gramsDerived;
|
||||
entity.CostPerPrint = request.CostPerPrint;
|
||||
entity.FilamentDiameterAtPrintMm = filamentDiameter;
|
||||
entity.MaterialDensityAtPrint = materialDensity;
|
||||
entity.Notes = request.Notes;
|
||||
|
||||
// If auto-deriving and the spool changed, also recompute COGS
|
||||
if (request.AutoDeriveGrams && spool.PurchasePrice.HasValue && gramsDerived > 0)
|
||||
{
|
||||
var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams;
|
||||
entity.CostPerPrint = Math.Round(gramsDerived * pricePerGram, 4);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties
|
||||
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
// ── PATCH /api/printjobs/{id}/status ──────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a print job with business rule validation.
|
||||
/// Allowed transitions:
|
||||
/// - Queued → Printing (job starts)
|
||||
/// - Printing → Completed (job finishes successfully)
|
||||
/// - Any → Cancelled (user cancels)
|
||||
/// - Any → Failed (job fails)
|
||||
/// - Queued → Queued, Printing → Printing (idempotent)
|
||||
///
|
||||
/// Forbidden transitions:
|
||||
/// - Completed → Queued, Printing (cannot reopen a completed job)
|
||||
/// - Cancelled → Queued, Printing, Completed (cannot reactivate a cancelled job)
|
||||
/// - Failed → Queued, Printing, Completed (cannot reactivate a failed job)
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the print job.</param>
|
||||
/// <param name="request">The status update request containing the new status.</param>
|
||||
/// <returns>The updated print job with the new status.</returns>
|
||||
/// <response code="200">Returns the print job with updated status.</response>
|
||||
/// <response code="404">If the print job with the given ID is not found.</response>
|
||||
/// <response code="400">If the status transition is invalid.</response>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(typeof(PrintJobResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrintJobResponse>> UpdatePrintJobStatus(
|
||||
Guid id, [FromBody] UpdatePrintJobStatusRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating print job {Id} status to {NewStatus}", id, request.Status);
|
||||
|
||||
var entity = await _dbContext.PrintJobs.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Print job {Id} not found for status update", id);
|
||||
return NotFound(new { error = $"Print job with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Parse the target status
|
||||
if (!Enum.TryParse<JobStatus>(request.Status, ignoreCase: true, out var newStatus))
|
||||
{
|
||||
return BadRequest(new { error = "Status must be one of: Queued, Printing, Completed, Cancelled, Failed." });
|
||||
}
|
||||
|
||||
// Validate the transition
|
||||
var transitionError = ValidateStatusTransition(entity.Status, newStatus);
|
||||
if (transitionError is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid status transition for print job {Id}: {CurrentStatus} → {NewStatus}. Reason: {Reason}",
|
||||
id, entity.Status, newStatus, transitionError);
|
||||
return BadRequest(new { error = transitionError });
|
||||
}
|
||||
|
||||
// Apply the transition
|
||||
var oldStatus = entity.Status;
|
||||
entity.Status = newStatus;
|
||||
|
||||
// Set timestamps based on transition
|
||||
if (newStatus == JobStatus.Printing && entity.StartedAt is null)
|
||||
entity.StartedAt = DateTime.UtcNow;
|
||||
|
||||
if (newStatus is JobStatus.Completed or JobStatus.Failed or JobStatus.Cancelled)
|
||||
entity.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Print job {Id} status updated: {OldStatus} → {NewStatus}",
|
||||
id, oldStatus, newStatus);
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(j => j.Printer).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(j => j.Spool).LoadAsync();
|
||||
|
||||
return Ok(MapToResponse(entity));
|
||||
}
|
||||
|
||||
// ── DELETE /api/printjobs/{id} ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a print job by setting its status to Cancelled.
|
||||
/// This preserves the job record for historical COGS reporting and audit trails.
|
||||
/// If the job is already in a terminal state (Cancelled, Failed, Completed),
|
||||
/// the operation is idempotent and returns 204.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the print job to soft-delete.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
/// <response code="204">The print job was successfully soft-deleted.</response>
|
||||
/// <response code="404">If the print job with the given ID is not found.</response>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeletePrintJob(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Soft-deleting print job {Id}", id);
|
||||
|
||||
var entity = await _dbContext.PrintJobs.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Print job {Id} not found for soft-delete", id);
|
||||
return NotFound(new { error = $"Print job with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// If already in a terminal/cancelled state, idempotent no-op
|
||||
if (entity.Status == JobStatus.Cancelled)
|
||||
{
|
||||
_logger.LogDebug("Print job {Id} is already cancelled — idempotent no-op", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Only cancel if it makes sense — don't override Completed status
|
||||
if (entity.Status == JobStatus.Completed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Print job {Id} is Completed — cannot soft-delete. Use explicit status change instead.", id);
|
||||
return BadRequest(new { error = "Cannot soft-delete a completed print job. Use PATCH /status to change status explicitly." });
|
||||
}
|
||||
|
||||
entity.Status = JobStatus.Cancelled;
|
||||
entity.CompletedAt = DateTime.UtcNow;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Print job {Id} soft-deleted (Status set to Cancelled)", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Gram Derivation Formula ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Derives grams consumed from millimeters extruded, filament diameter, and material density.
|
||||
/// Formula: grams = mm_extruded × cross_section_area_mm² × (density_g_per_cm³ / 1000)
|
||||
/// where cross_section_area = π × (diameter_mm / 2)²
|
||||
/// The density division by 1000 converts g/cm³ to g/mm³.
|
||||
/// </summary>
|
||||
/// <param name="mmExtruded">Total millimeters of filament extruded.</param>
|
||||
/// <param name="filamentDiameterMm">Filament diameter in mm (typically 1.75mm).</param>
|
||||
/// <param name="densityGPerCm3">Material density in g/cm³ (e.g., 1.24 for PLA).</param>
|
||||
/// <returns>Derived grams consumed, rounded to 2 decimal places.</returns>
|
||||
private static decimal DeriveGrams(decimal mmExtruded, decimal filamentDiameterMm, decimal densityGPerCm3)
|
||||
{
|
||||
// Cross-section area in mm²: π × r² where r = diameter/2
|
||||
var radiusMm = filamentDiameterMm / 2m;
|
||||
var crossSectionAreaMm2 = Math.Round(Math.PI * (double)(radiusMm * radiusMm), 8);
|
||||
|
||||
// Convert density from g/cm³ to g/mm³ by dividing by 1000
|
||||
var densityGMm3 = (double)densityGPerCm3 / 1000.0;
|
||||
|
||||
// grams = mm × area × density
|
||||
var grams = (double)mmExtruded * crossSectionAreaMm2 * densityGMm3;
|
||||
|
||||
return Math.Round((decimal)grams, 2);
|
||||
}
|
||||
|
||||
// ── Status Transition Validation ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a status transition is allowed by business rules.
|
||||
/// Returns null if the transition is valid, or an error message if not.
|
||||
/// </summary>
|
||||
/// <param name="currentStatus">The current status of the print job.</param>
|
||||
/// <param name="newStatus">The desired new status.</param>
|
||||
/// <returns>null if valid; an error message string if invalid.</returns>
|
||||
private static string? ValidateStatusTransition(JobStatus currentStatus, JobStatus newStatus)
|
||||
{
|
||||
// Idempotent transitions are always allowed
|
||||
if (currentStatus == newStatus)
|
||||
return null;
|
||||
|
||||
// Any state can transition to Cancelled or Failed
|
||||
if (newStatus is JobStatus.Cancelled or JobStatus.Failed)
|
||||
return null;
|
||||
|
||||
// Queued can transition to Printing
|
||||
if (currentStatus == JobStatus.Queued && newStatus == JobStatus.Printing)
|
||||
return null;
|
||||
|
||||
// Printing can transition to Completed
|
||||
if (currentStatus == JobStatus.Printing && newStatus == JobStatus.Completed)
|
||||
return null;
|
||||
|
||||
// All other transitions are forbidden
|
||||
return currentStatus switch
|
||||
{
|
||||
JobStatus.Completed =>
|
||||
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Completed jobs cannot be reactivated.",
|
||||
JobStatus.Cancelled =>
|
||||
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Cancelled jobs cannot be reactivated.",
|
||||
JobStatus.Failed =>
|
||||
$"Cannot change status from '{currentStatus}' to '{newStatus}'. Failed jobs cannot be reactivated.",
|
||||
JobStatus.Printing =>
|
||||
$"Cannot change status from '{currentStatus}' to '{newStatus}'. A printing job can only transition to Completed, Cancelled, or Failed.",
|
||||
JobStatus.Queued =>
|
||||
$"Cannot change status from '{currentStatus}' to '{newStatus}'. A queued job can only transition to Printing, Cancelled, or Failed.",
|
||||
_ =>
|
||||
$"Invalid status transition from '{currentStatus}' to '{newStatus}'."
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mapping Helper ─────────────────────────────────────────────
|
||||
|
||||
private static PrintJobResponse MapToResponse(PrintJob j) => new()
|
||||
{
|
||||
Id = j.Id,
|
||||
PrinterId = j.PrinterId,
|
||||
PrinterName = j.Printer?.Name ?? string.Empty,
|
||||
SpoolId = j.SpoolId,
|
||||
SpoolSerial = j.Spool?.SpoolSerial ?? string.Empty,
|
||||
PrintName = j.PrintName,
|
||||
GcodeFilePath = j.GcodeFilePath,
|
||||
MmExtruded = j.MmExtruded,
|
||||
GramsDerived = j.GramsDerived,
|
||||
CostPerPrint = j.CostPerPrint,
|
||||
StartedAt = j.StartedAt,
|
||||
CompletedAt = j.CompletedAt,
|
||||
Status = j.Status.ToString(),
|
||||
DataSource = j.DataSource.ToString(),
|
||||
FilamentDiameterAtPrintMm = j.FilamentDiameterAtPrintMm,
|
||||
MaterialDensityAtPrint = j.MaterialDensityAtPrint,
|
||||
Notes = j.Notes,
|
||||
CreatedAt = j.CreatedAt,
|
||||
UpdatedAt = j.UpdatedAt
|
||||
};
|
||||
}
|
||||
297
backend/API/Controllers/PrintersController.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using Extrudex.API.DTOs.Printers;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing 3D printers in the fleet.
|
||||
/// Provides CRUD operations, a lightweight status endpoint, and soft-delete.
|
||||
/// Real-time printer status is also available via the SignalR PrinterHub.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/printers")]
|
||||
public class PrintersController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<PrintersController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PrintersController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public PrintersController(ExtrudexDbContext dbContext, ILogger<PrintersController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all printers, optionally filtered by active status.
|
||||
/// Results are ordered by printer name alphabetically.
|
||||
/// </summary>
|
||||
/// <param name="isActive">
|
||||
/// Optional filter: <c>true</c> for active printers only,
|
||||
/// <c>false</c> for inactive (soft-deleted) printers,
|
||||
/// omit for all printers.
|
||||
/// </param>
|
||||
/// <returns>A list of printers matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the list of printers.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<PrinterResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<PrinterResponse>>> GetPrinters(
|
||||
[FromQuery] bool? isActive = null)
|
||||
{
|
||||
_logger.LogDebug("Getting printers, isActive filter: {IsActive}", isActive);
|
||||
|
||||
var query = _dbContext.Printers.AsQueryable();
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(p => p.IsActive == isActive.Value);
|
||||
|
||||
var printers = await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => MapToPrinterResponse(p))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(printers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific printer by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer.</param>
|
||||
/// <returns>The full printer details including connection configuration.</returns>
|
||||
/// <response code="200">Returns the printer details.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PrinterResponse>> GetPrinter(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting printer {Id}", id);
|
||||
|
||||
var printer = await _dbContext.Printers.FindAsync(id);
|
||||
if (printer is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToPrinterResponse(printer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the real-time connection/health status of a specific printer.
|
||||
/// This is a lightweight endpoint suitable for polling or dashboard displays.
|
||||
/// For push-based real-time updates, subscribe to the SignalR PrinterHub instead.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer.</param>
|
||||
/// <returns>The printer's current status, last-seen timestamp, and active flag.</returns>
|
||||
/// <response code="200">Returns the printer status.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpGet("{id:guid}/status")]
|
||||
[ProducesResponseType(typeof(PrinterStatusResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PrinterStatusResponse>> GetPrinterStatus(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting status for printer {Id}", id);
|
||||
|
||||
var printer = await _dbContext.Printers.FindAsync(id);
|
||||
if (printer is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for status check", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(new PrinterStatusResponse
|
||||
{
|
||||
Id = printer.Id,
|
||||
Name = printer.Name,
|
||||
Status = printer.Status.ToString(),
|
||||
LastSeenAt = printer.LastSeenAt,
|
||||
IsActive = printer.IsActive
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new printer in the fleet.
|
||||
/// The printer is created with an initial status of Offline.
|
||||
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
|
||||
/// </summary>
|
||||
/// <param name="request">The printer registration request with connection details.</param>
|
||||
/// <returns>The newly created printer.</returns>
|
||||
/// <response code="201">The printer was created successfully.</response>
|
||||
/// <response code="400">The request body is invalid or validation failed.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrinterResponse>> CreatePrinter(
|
||||
[FromBody] CreatePrinterRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
_logger.LogInformation("Creating printer: {Name} ({Manufacturer} {Model})",
|
||||
request.Name, request.Manufacturer, request.Model);
|
||||
|
||||
// Parse enums from string values
|
||||
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
|
||||
}
|
||||
|
||||
// Default port based on connection type
|
||||
var port = request.Port > 0
|
||||
? request.Port
|
||||
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
|
||||
|
||||
var entity = new Printer
|
||||
{
|
||||
Name = request.Name,
|
||||
Manufacturer = request.Manufacturer,
|
||||
Model = request.Model,
|
||||
PrinterType = printerType,
|
||||
ConnectionType = connectionType,
|
||||
HostnameOrIp = request.HostnameOrIp,
|
||||
Port = port,
|
||||
MqttUsername = request.MqttUsername,
|
||||
MqttPassword = request.MqttPassword,
|
||||
MqttUseTls = request.MqttUseTls,
|
||||
ApiKey = request.ApiKey,
|
||||
IsActive = request.IsActive,
|
||||
Status = PrinterStatus.Offline
|
||||
};
|
||||
|
||||
_dbContext.Printers.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
var response = MapToPrinterResponse(entity);
|
||||
return CreatedAtAction(nameof(GetPrinter), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing printer's configuration and connection info.
|
||||
/// Uses full replacement semantics — all fields are written.
|
||||
/// The port defaults to 8883 for MQTT or 7125 for Moonraker if not specified.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer to update.</param>
|
||||
/// <param name="request">The printer update request with new values.</param>
|
||||
/// <returns>The updated printer.</returns>
|
||||
/// <response code="200">The printer was updated successfully.</response>
|
||||
/// <response code="400">The request body is invalid or validation failed.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(PrinterResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<PrinterResponse>> UpdatePrinter(
|
||||
Guid id, [FromBody] UpdatePrinterRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return BadRequest(ModelState);
|
||||
|
||||
_logger.LogInformation("Updating printer {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Printers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Parse enums from string values
|
||||
if (!Enum.TryParse<PrinterType>(request.PrinterType, true, out var printerType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid PrinterType: '{request.PrinterType}'. Must be 'Fdm' or 'Resin'." });
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ConnectionType>(request.ConnectionType, true, out var connectionType))
|
||||
{
|
||||
return BadRequest(new { error = $"Invalid ConnectionType: '{request.ConnectionType}'. Must be 'Mqtt' or 'Moonraker'." });
|
||||
}
|
||||
|
||||
var port = request.Port > 0
|
||||
? request.Port
|
||||
: connectionType == ConnectionType.Mqtt ? 8883 : 7125;
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.Manufacturer = request.Manufacturer;
|
||||
entity.Model = request.Model;
|
||||
entity.PrinterType = printerType;
|
||||
entity.ConnectionType = connectionType;
|
||||
entity.HostnameOrIp = request.HostnameOrIp;
|
||||
entity.Port = port;
|
||||
entity.MqttUsername = request.MqttUsername;
|
||||
entity.MqttPassword = request.MqttPassword;
|
||||
entity.MqttUseTls = request.MqttUseTls;
|
||||
entity.ApiKey = request.ApiKey;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToPrinterResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a printer by setting <c>IsActive</c> to <c>false</c>.
|
||||
/// The printer record is retained for historical and audit purposes.
|
||||
/// Soft-deleted printers can be recovered via PUT with <c>IsActive = true</c>.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the printer to soft-delete.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
/// <response code="204">The printer was soft-deleted successfully.</response>
|
||||
/// <response code="404">The printer was not found.</response>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeletePrinter(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Soft-deleting printer {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Printers.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Printer {Id} not found for soft-delete", id);
|
||||
return NotFound(new { error = $"Printer with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
entity.IsActive = false;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="Printer"/> domain entity to a <see cref="PrinterResponse"/> DTO.
|
||||
/// </summary>
|
||||
/// <param name="p">The printer entity to map.</param>
|
||||
/// <returns>A response DTO suitable for API output.</returns>
|
||||
private static PrinterResponse MapToPrinterResponse(Printer p) => new()
|
||||
{
|
||||
Id = p.Id,
|
||||
Status = p.Status.ToString(),
|
||||
Name = p.Name,
|
||||
Manufacturer = p.Manufacturer,
|
||||
Model = p.Model,
|
||||
PrinterType = p.PrinterType.ToString(),
|
||||
ConnectionType = p.ConnectionType.ToString(),
|
||||
HostnameOrIp = p.HostnameOrIp,
|
||||
Port = p.Port,
|
||||
IsActive = p.IsActive,
|
||||
LastSeenAt = p.LastSeenAt,
|
||||
CreatedAt = p.CreatedAt,
|
||||
UpdatedAt = p.UpdatedAt
|
||||
};
|
||||
}
|
||||
101
backend/API/Controllers/QrController.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for generating QR codes for Extrudex resources (spools, printers, locations).
|
||||
/// Returns QR codes as PNG or SVG images optimized for small label printing.
|
||||
/// QR codes encode deep links that resolve to the resource's detail page.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/qr")]
|
||||
public class QrController : ControllerBase
|
||||
{
|
||||
private readonly IQrCodeService _qrCodeService;
|
||||
private readonly ILogger<QrController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QrController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="qrCodeService">The QR code generation service.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public QrController(IQrCodeService qrCodeService, ILogger<QrController> logger)
|
||||
{
|
||||
_qrCodeService = qrCodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a QR code for the specified resource type and ID.
|
||||
/// Returns the QR code as a PNG image by default, or as SVG when
|
||||
/// the <c>format</c> query parameter is set to "svg".
|
||||
/// </summary>
|
||||
/// <param name="resourceType">
|
||||
/// The type of resource: <c>spool</c>, <c>printer</c>, or <c>location</c>.
|
||||
/// </param>
|
||||
/// <param name="id">The unique identifier of the resource.</param>
|
||||
/// <param name="format">
|
||||
/// Optional output format: <c>png</c> (default) or <c>svg</c>.
|
||||
/// SVG is resolution-independent and ideal for printing at any scale.
|
||||
/// </param>
|
||||
/// <param name="size">
|
||||
/// Optional pixel density per QR module for PNG output (default: 20).
|
||||
/// Ignored for SVG output. Range: 4–40.
|
||||
/// </param>
|
||||
/// <returns>The QR code image in the requested format.</returns>
|
||||
/// <response code="200">Returns the QR code image.</response>
|
||||
/// <response code="400">If the resource type is invalid or parameters are out of range.</response>
|
||||
[HttpGet("{resourceType}/{id:guid}")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/png")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK, "image/svg+xml")]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult GetQrCode(
|
||||
string resourceType,
|
||||
Guid id,
|
||||
[FromQuery] string format = "png",
|
||||
[FromQuery] int size = 20)
|
||||
{
|
||||
_logger.LogDebug("Generating QR code: resourceType={ResourceType}, id={Id}, format={Format}, size={Size}",
|
||||
resourceType, id, format, size);
|
||||
|
||||
// Parse resource type
|
||||
if (!Enum.TryParse<QrResourceType>(resourceType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Invalid resource type: '{resourceType}'. " +
|
||||
$"Valid values are: spool, printer, location."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate format
|
||||
format = format.ToLowerInvariant();
|
||||
if (format is not ("png" or "svg"))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Invalid format: '{format}'. Valid values are: png, svg."
|
||||
});
|
||||
}
|
||||
|
||||
// Validate size for PNG
|
||||
if (format == "png" && (size < 4 || size > 40))
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = $"Size must be between 4 and 40. Received: {size}."
|
||||
});
|
||||
}
|
||||
|
||||
if (format == "svg")
|
||||
{
|
||||
var svg = _qrCodeService.GenerateSvg(parsedType, id);
|
||||
return Content(svg, "image/svg+xml");
|
||||
}
|
||||
|
||||
var pngBytes = _qrCodeService.GeneratePng(parsedType, id, size);
|
||||
return File(pngBytes, "image/png");
|
||||
}
|
||||
}
|
||||
329
backend/API/Controllers/SpoolsController.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using Extrudex.API.DTOs;
|
||||
using Extrudex.API.DTOs.Spools;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing filament spools — the core inventory unit of Extrudex.
|
||||
/// Supports full CRUD with pagination, filtering by material and active status,
|
||||
/// and soft-delete semantics (DELETE sets IsActive = false).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/spools")]
|
||||
public class SpoolsController : ControllerBase
|
||||
{
|
||||
private readonly ExtrudexDbContext _dbContext;
|
||||
private readonly ILogger<SpoolsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SpoolsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for data access.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
public SpoolsController(ExtrudexDbContext dbContext, ILogger<SpoolsController> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a paginated list of spools with optional filtering by material and active status.
|
||||
/// Results are ordered by creation date (newest first) and include denormalized material names.
|
||||
/// </summary>
|
||||
/// <param name="query">Optional query parameters for pagination and filtering.</param>
|
||||
/// <returns>A paginated list of spools matching the filter criteria.</returns>
|
||||
/// <response code="200">Returns the paginated list of spools.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SpoolResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SpoolResponse>>> GetSpools(
|
||||
[FromQuery] SpoolQueryParameters query)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Getting spools: pageNumber={PageNumber}, pageSize={PageSize}, " +
|
||||
"materialBaseId={MaterialBaseId}, materialFinishId={MaterialFinishId}, isActive={IsActive}",
|
||||
query.PageNumber, query.PageSize, query.MaterialBaseId, query.MaterialFinishId, query.IsActive);
|
||||
|
||||
// Clamp pagination values
|
||||
var pageNumber = Math.Max(1, query.PageNumber);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var spoolQuery = _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (query.MaterialBaseId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialBaseId == query.MaterialBaseId.Value);
|
||||
|
||||
if (query.MaterialFinishId.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.MaterialFinishId == query.MaterialFinishId.Value);
|
||||
|
||||
if (query.IsActive.HasValue)
|
||||
spoolQuery = spoolQuery.Where(s => s.IsActive == query.IsActive.Value);
|
||||
|
||||
var totalCount = await spoolQuery.CountAsync();
|
||||
|
||||
var items = await spoolQuery
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(s => MapToSpoolResponse(s))
|
||||
.ToListAsync();
|
||||
|
||||
var response = new PagedResponse<SpoolResponse>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific spool by its unique identifier.
|
||||
/// Includes denormalized material names for display.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool.</param>
|
||||
/// <returns>The spool details.</returns>
|
||||
/// <response code="200">Returns the spool details.</response>
|
||||
/// <response code="404">If the spool with the given ID is not found.</response>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SpoolResponse>> GetSpool(Guid id)
|
||||
{
|
||||
_logger.LogDebug("Getting spool {Id}", id);
|
||||
|
||||
var spool = await _dbContext.Spools
|
||||
.Include(s => s.MaterialBase)
|
||||
.Include(s => s.MaterialFinish)
|
||||
.Include(s => s.MaterialModifier)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (spool is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
return Ok(MapToSpoolResponse(spool));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new spool record. Validates that all foreign keys reference existing entities
|
||||
/// and that the SpoolSerial is unique across all spools.
|
||||
/// </summary>
|
||||
/// <param name="request">The spool creation request with all required fields.</param>
|
||||
/// <returns>The newly created spool with generated ID and timestamps.</returns>
|
||||
/// <response code="201">Returns the created spool with location header.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<SpoolResponse>> CreateSpool([FromBody] CreateSpoolRequest request)
|
||||
{
|
||||
_logger.LogInformation("Creating spool: {Serial} - {Brand} {Color}",
|
||||
request.SpoolSerial, request.Brand, request.ColorName);
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Validate serial uniqueness
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
var entity = new Spool
|
||||
{
|
||||
MaterialBaseId = request.MaterialBaseId,
|
||||
MaterialFinishId = request.MaterialFinishId,
|
||||
MaterialModifierId = request.MaterialModifierId,
|
||||
Brand = request.Brand,
|
||||
ColorName = request.ColorName,
|
||||
ColorHex = request.ColorHex,
|
||||
WeightTotalGrams = request.WeightTotalGrams,
|
||||
WeightRemainingGrams = request.WeightRemainingGrams,
|
||||
FilamentDiameterMm = request.FilamentDiameterMm,
|
||||
SpoolSerial = request.SpoolSerial,
|
||||
PurchasePrice = request.PurchasePrice,
|
||||
PurchaseDate = request.PurchaseDate,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_dbContext.Spools.Add(entity);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties for the response
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
var response = MapToSpoolResponse(entity);
|
||||
return CreatedAtAction(nameof(GetSpool), new { id = entity.Id }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing spool. Validates foreign keys and serial uniqueness
|
||||
/// (excluding the current spool from the uniqueness check).
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool to update.</param>
|
||||
/// <param name="request">The spool update request with all required fields.</param>
|
||||
/// <returns>The updated spool with current timestamps.</returns>
|
||||
/// <response code="200">Returns the updated spool.</response>
|
||||
/// <response code="404">If the spool with the given ID is not found.</response>
|
||||
/// <response code="400">If the request is invalid or foreign keys don't exist.</response>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SpoolResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<SpoolResponse>> UpdateSpool(
|
||||
Guid id, [FromBody] UpdateSpoolRequest request)
|
||||
{
|
||||
_logger.LogInformation("Updating spool {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found for update", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
// Validate foreign keys exist
|
||||
var materialBase = await _dbContext.MaterialBases.FindAsync(request.MaterialBaseId);
|
||||
if (materialBase is null)
|
||||
return BadRequest(new { error = $"MaterialBase with ID '{request.MaterialBaseId}' not found." });
|
||||
|
||||
var materialFinish = await _dbContext.MaterialFinishes.FindAsync(request.MaterialFinishId);
|
||||
if (materialFinish is null)
|
||||
return BadRequest(new { error = $"MaterialFinish with ID '{request.MaterialFinishId}' not found." });
|
||||
|
||||
if (request.MaterialModifierId.HasValue)
|
||||
{
|
||||
var modifier = await _dbContext.MaterialModifiers.FindAsync(request.MaterialModifierId.Value);
|
||||
if (modifier is null)
|
||||
return BadRequest(new { error = $"MaterialModifier with ID '{request.MaterialModifierId}' not found." });
|
||||
}
|
||||
|
||||
// Check serial uniqueness (excluding current spool)
|
||||
var serialExists = await _dbContext.Spools
|
||||
.AnyAsync(s => s.SpoolSerial == request.SpoolSerial && s.Id != id);
|
||||
if (serialExists)
|
||||
return BadRequest(new { error = $"Spool with serial '{request.SpoolSerial}' already exists." });
|
||||
|
||||
// Validate remaining weight doesn't exceed total weight
|
||||
if (request.WeightRemainingGrams > request.WeightTotalGrams)
|
||||
return BadRequest(new { error = "WeightRemainingGrams cannot exceed WeightTotalGrams." });
|
||||
|
||||
entity.MaterialBaseId = request.MaterialBaseId;
|
||||
entity.MaterialFinishId = request.MaterialFinishId;
|
||||
entity.MaterialModifierId = request.MaterialModifierId;
|
||||
entity.Brand = request.Brand;
|
||||
entity.ColorName = request.ColorName;
|
||||
entity.ColorHex = request.ColorHex;
|
||||
entity.WeightTotalGrams = request.WeightTotalGrams;
|
||||
entity.WeightRemainingGrams = request.WeightRemainingGrams;
|
||||
entity.FilamentDiameterMm = request.FilamentDiameterMm;
|
||||
entity.SpoolSerial = request.SpoolSerial;
|
||||
entity.PurchasePrice = request.PurchasePrice;
|
||||
entity.PurchaseDate = request.PurchaseDate;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
// Reload navigation properties
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialBase).LoadAsync();
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialFinish).LoadAsync();
|
||||
if (entity.MaterialModifierId.HasValue)
|
||||
await _dbContext.Entry(entity).Reference(s => s.MaterialModifier).LoadAsync();
|
||||
|
||||
return Ok(MapToSpoolResponse(entity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a spool by setting IsActive = false. The spool is retained
|
||||
/// in the database for historical COGS and print job records.
|
||||
/// This is NOT a hard delete — the data is preserved.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the spool to soft-delete.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
/// <response code="204">The spool was successfully soft-deleted.</response>
|
||||
/// <response code="404">If the spool with the given ID is not found.</response>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSpool(Guid id)
|
||||
{
|
||||
_logger.LogInformation("Soft-deleting spool {Id}", id);
|
||||
|
||||
var entity = await _dbContext.Spools.FindAsync(id);
|
||||
if (entity is null)
|
||||
{
|
||||
_logger.LogWarning("Spool {Id} not found for soft-delete", id);
|
||||
return NotFound(new { error = $"Spool with ID '{id}' not found." });
|
||||
}
|
||||
|
||||
if (!entity.IsActive)
|
||||
{
|
||||
_logger.LogDebug("Spool {Id} is already inactive — idempotent no-op", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
entity.IsActive = false;
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Spool {Id} soft-deleted (IsActive = false)", id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Mapping helper ─────────────────────────────────────────
|
||||
|
||||
private static SpoolResponse MapToSpoolResponse(Spool s) => new()
|
||||
{
|
||||
Id = s.Id,
|
||||
MaterialBaseId = s.MaterialBaseId,
|
||||
MaterialBaseName = s.MaterialBase?.Name ?? string.Empty,
|
||||
MaterialFinishId = s.MaterialFinishId,
|
||||
MaterialFinishName = s.MaterialFinish?.Name ?? string.Empty,
|
||||
MaterialModifierId = s.MaterialModifierId,
|
||||
MaterialModifierName = s.MaterialModifier?.Name,
|
||||
Brand = s.Brand,
|
||||
ColorName = s.ColorName,
|
||||
ColorHex = s.ColorHex,
|
||||
WeightTotalGrams = s.WeightTotalGrams,
|
||||
WeightRemainingGrams = s.WeightRemainingGrams,
|
||||
FilamentDiameterMm = s.FilamentDiameterMm,
|
||||
SpoolSerial = s.SpoolSerial,
|
||||
PurchasePrice = s.PurchasePrice,
|
||||
PurchaseDate = s.PurchaseDate,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt
|
||||
};
|
||||
}
|
||||
199
backend/API/DTOs/Filaments/FilamentDtos.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Filaments;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for a filament spool — the core inventory unit of Extrudex.
|
||||
/// Contains all spool details including denormalized material names for display.
|
||||
/// </summary>
|
||||
public class FilamentResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the filament spool.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the base material.</summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Name of the base material (e.g., "PLA", "PETG").</summary>
|
||||
public string MaterialBaseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the material finish.</summary>
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Name of the material finish (e.g., "Basic", "Matte").</summary>
|
||||
public string MaterialFinishName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Name of the material modifier (e.g., "Carbon Fiber"). Null if none.</summary>
|
||||
public string? MaterialModifierName { get; set; }
|
||||
|
||||
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker").</summary>
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name (e.g., "Fire Engine Red").</summary>
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000").</summary>
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full.</summary>
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams.</summary>
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in millimeters. Typically 1.75mm.</summary>
|
||||
public decimal FilamentDiameterMm { get; set; }
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique.</summary>
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Purchase price per spool. Null if not tracked.</summary>
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Date the spool was purchased or received.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is currently active and available.</summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the QR code image for this spool.
|
||||
/// Encodes a deep link to the spool's detail page.
|
||||
/// </summary>
|
||||
public string QrCodeUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new filament spool.
|
||||
/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default.
|
||||
/// </summary>
|
||||
public class CreateFilamentRequest
|
||||
{
|
||||
/// <summary>Foreign key to the base material. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the material finish. Required — default is "Basic".</summary>
|
||||
[Required(ErrorMessage = "MaterialFinishId is required.")]
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "Brand is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "ColorName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
|
||||
[Required(ErrorMessage = "ColorHex is required.")]
|
||||
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
|
||||
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "WeightTotalGrams is required.")]
|
||||
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
|
||||
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
|
||||
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in mm. Defaults to 1.75. Must be greater than zero.</summary>
|
||||
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
|
||||
public decimal FilamentDiameterMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "SpoolSerial is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
|
||||
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Optional purchase date. Must be a valid date if provided.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is active. Defaults to true.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing filament spool.
|
||||
/// All required fields must be provided for a full update.
|
||||
/// </summary>
|
||||
public class UpdateFilamentRequest
|
||||
{
|
||||
/// <summary>Foreign key to the base material. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the material finish. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialFinishId is required.")]
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Brand name. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "Brand is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "ColorName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
|
||||
[Required(ErrorMessage = "ColorHex is required.")]
|
||||
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
|
||||
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "WeightTotalGrams is required.")]
|
||||
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
|
||||
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
|
||||
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in mm. Must be greater than zero.</summary>
|
||||
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
|
||||
public decimal FilamentDiameterMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "SpoolSerial is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
|
||||
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Optional purchase date.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is active.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
33
backend/API/DTOs/Filaments/FilamentQueryDtos.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Filaments;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for filtering and paginating the filament list endpoint.
|
||||
/// All parameters are optional — defaults are applied when not provided.
|
||||
/// </summary>
|
||||
public class FilamentQueryParameters
|
||||
{
|
||||
/// <summary>Page number (1-based). Defaults to 1.</summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
|
||||
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>Optional filter by material base ID.</summary>
|
||||
public Guid? MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by material finish ID.</summary>
|
||||
public Guid? MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by material modifier ID.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by brand name (case-insensitive partial match).</summary>
|
||||
public string? Brand { get; set; }
|
||||
|
||||
/// <summary>Optional filter by active status. True = active only, False = inactive only.</summary>
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
56
backend/API/DTOs/Materials/MaterialBaseDtos.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Materials;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for MaterialBase entity.
|
||||
/// </summary>
|
||||
public class MaterialBaseResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the material base.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS").</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Density in g/cm³ used for grams-derived calculations.</summary>
|
||||
public decimal DensityGperCm3 { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new MaterialBase.
|
||||
/// </summary>
|
||||
public class CreateMaterialBaseRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Density in g/cm³. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "Density is required.")]
|
||||
[Range(0.001, 100.0, ErrorMessage = "Density must be between 0.001 and 100.0 g/cm³.")]
|
||||
public decimal DensityGperCm3 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing MaterialBase.
|
||||
/// </summary>
|
||||
public class UpdateMaterialBaseRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "PLA", "PETG", "ABS"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Density in g/cm³. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "Density is required.")]
|
||||
[Range(0.001, 100.0, ErrorMessage = "Density must be between 0.001 and 100.0 g/cm³.")]
|
||||
public decimal DensityGperCm3 { get; set; }
|
||||
}
|
||||
57
backend/API/DTOs/Materials/MaterialFinishDtos.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Materials;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for MaterialFinish entity.
|
||||
/// </summary>
|
||||
public class MaterialFinishResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the finish.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk").</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase.</summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Name of the parent material base (for display).</summary>
|
||||
public string MaterialBaseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new MaterialFinish.
|
||||
/// </summary>
|
||||
public class CreateMaterialFinishRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing MaterialFinish.
|
||||
/// </summary>
|
||||
public class UpdateMaterialFinishRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "Basic", "Matte", "Silk"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
}
|
||||
57
backend/API/DTOs/Materials/MaterialModifierDtos.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Materials;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for MaterialModifier entity.
|
||||
/// </summary>
|
||||
public class MaterialModifierResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the modifier.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill").</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase.</summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Name of the parent material base (for display).</summary>
|
||||
public string MaterialBaseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new MaterialModifier.
|
||||
/// </summary>
|
||||
public class CreateMaterialModifierRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing MaterialModifier.
|
||||
/// </summary>
|
||||
public class UpdateMaterialModifierRequest
|
||||
{
|
||||
/// <summary>Human-readable name (e.g., "Carbon Fiber", "Wood Fill"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 50 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the parent MaterialBase. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
}
|
||||
30
backend/API/DTOs/PagedResponse.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Extrudex.API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Generic paginated response wrapper for list endpoints.
|
||||
/// Provides pagination metadata alongside the result items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items in the page.</typeparam>
|
||||
public class PagedResponse<T>
|
||||
{
|
||||
/// <summary>The items in the current page.</summary>
|
||||
public IReadOnlyList<T> Items { get; set; } = [];
|
||||
|
||||
/// <summary>Total number of items across all pages.</summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>Current page number (1-based).</summary>
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
/// <summary>Number of items per page.</summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>Total number of pages.</summary>
|
||||
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(TotalCount / (double)PageSize) : 0;
|
||||
|
||||
/// <summary>Whether there is a next page.</summary>
|
||||
public bool HasNextPage => PageNumber < TotalPages;
|
||||
|
||||
/// <summary>Whether there is a previous page.</summary>
|
||||
public bool HasPreviousPage => PageNumber > 1;
|
||||
}
|
||||
223
backend/API/DTOs/PrintJobs/PrintJobDtos.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.PrintJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for PrintJob entity. Contains all job details including
|
||||
/// denormalized printer name and spool serial for display.
|
||||
/// Audit snapshots (filament diameter and material density) preserve COGS accuracy
|
||||
/// even if the source data changes after the print.
|
||||
/// </summary>
|
||||
public class PrintJobResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the print job.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the printer that executed this job.</summary>
|
||||
public Guid PrinterId { get; set; }
|
||||
|
||||
/// <summary>Name of the printer that executed this job.</summary>
|
||||
public string PrinterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the spool that provided filament.</summary>
|
||||
public Guid SpoolId { get; set; }
|
||||
|
||||
/// <summary>Serial number of the spool that provided filament.</summary>
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable name or identifier for the print job.</summary>
|
||||
public string PrintName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Path or filename of the G-code file.</summary>
|
||||
public string? GcodeFilePath { get; set; }
|
||||
|
||||
/// <summary>Total millimeters of filament extruded during this print.</summary>
|
||||
public decimal MmExtruded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived grams consumed for this print, calculated as:
|
||||
/// mm_extruded × cross_section_area × material_density.
|
||||
/// Cross-section area = π × (filament_diameter / 2)² in mm².
|
||||
/// Density converted from g/cm³ to g/mm³ by dividing by 1000.
|
||||
/// </summary>
|
||||
public decimal GramsDerived { get; set; }
|
||||
|
||||
/// <summary>Calculated cost of goods sold (COGS) for this print job.</summary>
|
||||
public decimal? CostPerPrint { get; set; }
|
||||
|
||||
/// <summary>Timestamp when the print job started (UTC).</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when the print job completed or failed (UTC).</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>Current status of the print job (Queued, Printing, Completed, Cancelled, Failed).</summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Data source that provided this job (Mqtt, Moonraker, Manual).</summary>
|
||||
public string DataSource { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Audit snapshot: filament diameter (mm) recorded at time of print.</summary>
|
||||
public decimal FilamentDiameterAtPrintMm { get; set; }
|
||||
|
||||
/// <summary>Audit snapshot: material density (g/cm³) recorded at time of print.</summary>
|
||||
public decimal MaterialDensityAtPrint { get; set; }
|
||||
|
||||
/// <summary>Optional notes about the print job.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new print job. The gram derivation formula
|
||||
/// (grams = mm_extruded × cross_section_area × material_density) can be
|
||||
/// auto-computed server-side when MmExtruded, FilamentDiameterAtPrintMm,
|
||||
/// and MaterialDensityAtPrint are provided. Alternatively, set AutoDeriveGrams
|
||||
/// to true and provide a SpoolId to pull density from the material base.
|
||||
/// </summary>
|
||||
public class CreatePrintJobRequest
|
||||
{
|
||||
/// <summary>Foreign key to the printer that will execute this job. Required.</summary>
|
||||
[Required(ErrorMessage = "PrinterId is required.")]
|
||||
public Guid PrinterId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the spool providing filament. Required.</summary>
|
||||
[Required(ErrorMessage = "SpoolId is required.")]
|
||||
public Guid SpoolId { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the print job. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "PrintName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "PrintName must be between 1 and 200 characters.")]
|
||||
public string PrintName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional path or filename of the G-code file. Max 500 characters.</summary>
|
||||
[StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")]
|
||||
public string? GcodeFilePath { get; set; }
|
||||
|
||||
/// <summary>Total millimeters of filament extruded. Must be non-negative. Defaults to 0.</summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")]
|
||||
public decimal MmExtruded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived grams consumed. If AutoDeriveGrams is true, this is computed
|
||||
/// server-side and the provided value is ignored. Must be non-negative if manually set.
|
||||
/// </summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")]
|
||||
public decimal GramsDerived { get; set; }
|
||||
|
||||
/// <summary>Optional calculated COGS. Must be non-negative if provided.</summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")]
|
||||
public decimal? CostPerPrint { get; set; }
|
||||
|
||||
/// <summary>Optional timestamp when the job started (UTC).</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Data source for this job. Must be "Mqtt", "Moonraker", or "Manual".
|
||||
/// Defaults to "Manual".
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "DataSource is required.")]
|
||||
[RegularExpression("^(Mqtt|Moonraker|Manual)$", ErrorMessage = "DataSource must be 'Mqtt', 'Moonraker', or 'Manual'.")]
|
||||
public string DataSource { get; set; } = "Manual";
|
||||
|
||||
/// <summary>
|
||||
/// Audit snapshot: filament diameter in mm at time of print. Must be greater than zero.
|
||||
/// Defaults to 1.75mm if not specified and AutoDeriveGrams is false.
|
||||
/// </summary>
|
||||
[Range(0.01, 100, ErrorMessage = "FilamentDiameterAtPrintMm must be between 0.01 and 100 mm.")]
|
||||
public decimal FilamentDiameterAtPrintMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>
|
||||
/// Audit snapshot: material density in g/cm³ at time of print. Must be greater than zero.
|
||||
/// If AutoDeriveGrams is true, this is populated from the spool's material base.
|
||||
/// </summary>
|
||||
[Range(0.001, 100, ErrorMessage = "MaterialDensityAtPrint must be between 0.001 and 100 g/cm³.")]
|
||||
public decimal MaterialDensityAtPrint { get; set; }
|
||||
|
||||
/// <summary>Optional notes about the print job. Max 2000 characters.</summary>
|
||||
[StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the server auto-derives GramsDerived, FilamentDiameterAtPrintMm,
|
||||
/// and MaterialDensityAtPrint from the spool's material data.
|
||||
/// MmExtruded must still be provided. Overrides manual GramsDerived.
|
||||
/// </summary>
|
||||
public bool AutoDeriveGrams { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing print job. Full replacement semantics —
|
||||
/// all required fields must be provided. Gram derivation can be recomputed
|
||||
/// by setting AutoDeriveGrams to true.
|
||||
/// </summary>
|
||||
public class UpdatePrintJobRequest
|
||||
{
|
||||
/// <summary>Foreign key to the printer. Required.</summary>
|
||||
[Required(ErrorMessage = "PrinterId is required.")]
|
||||
public Guid PrinterId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the spool. Required.</summary>
|
||||
[Required(ErrorMessage = "SpoolId is required.")]
|
||||
public Guid SpoolId { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the print job. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "PrintName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "PrintName must be between 1 and 200 characters.")]
|
||||
public string PrintName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional path or filename of the G-code file. Max 500 characters.</summary>
|
||||
[StringLength(500, ErrorMessage = "GcodeFilePath must not exceed 500 characters.")]
|
||||
public string? GcodeFilePath { get; set; }
|
||||
|
||||
/// <summary>Total millimeters of filament extruded. Must be non-negative.</summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "MmExtruded must be non-negative.")]
|
||||
public decimal MmExtruded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived grams consumed. If AutoDeriveGrams is true, this is recomputed
|
||||
/// server-side and the provided value is ignored.
|
||||
/// </summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "GramsDerived must be non-negative.")]
|
||||
public decimal GramsDerived { get; set; }
|
||||
|
||||
/// <summary>Optional calculated COGS. Must be non-negative if provided.</summary>
|
||||
[Range(0, double.MaxValue, ErrorMessage = "CostPerPrint must be non-negative.")]
|
||||
public decimal? CostPerPrint { get; set; }
|
||||
|
||||
/// <summary>Optional notes about the print job. Max 2000 characters.</summary>
|
||||
[StringLength(2000, ErrorMessage = "Notes must not exceed 2000 characters.")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the server recomputes GramsDerived, FilamentDiameterAtPrintMm,
|
||||
/// and MaterialDensityAtPrint from the spool's current material data.
|
||||
/// MmExtruded must still be provided.
|
||||
/// </summary>
|
||||
public bool AutoDeriveGrams { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for the PATCH /api/printjobs/{id}/status endpoint.
|
||||
/// Validates that the transition is allowed by business rules:
|
||||
/// - Can always go to Cancelled or Failed from any state.
|
||||
/// - Can move from Queued → Printing, Printing → Completed.
|
||||
/// - Cannot move from Completed back to Printing or Queued.
|
||||
/// - Cannot move from Cancelled back to any active state.
|
||||
/// </summary>
|
||||
public class UpdatePrintJobStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// New status for the print job. Must be one of: Queued, Printing, Completed, Cancelled, Failed.
|
||||
/// Case-insensitive.
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "Status is required.")]
|
||||
[RegularExpression("^(Queued|Printing|Completed|Cancelled|Failed)$",
|
||||
ErrorMessage = "Status must be one of: Queued, Printing, Completed, Cancelled, Failed.")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
30
backend/API/DTOs/PrintJobs/PrintJobQueryDtos.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.PrintJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for filtering and paginating the print job list endpoint.
|
||||
/// All parameters are optional — defaults are applied when not provided.
|
||||
/// </summary>
|
||||
public class PrintJobQueryParameters
|
||||
{
|
||||
/// <summary>Page number (1-based). Defaults to 1.</summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
|
||||
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>Optional filter by printer ID. Only returns jobs for this printer.</summary>
|
||||
public Guid? PrinterId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by spool ID. Only returns jobs that used this spool.</summary>
|
||||
public Guid? SpoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional filter by job status. Must be a valid JobStatus value
|
||||
/// (Queued, Printing, Completed, Cancelled, Failed). Case-insensitive.
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
190
backend/API/DTOs/Printers/PrinterDtos.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Printers;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for a Printer entity. Contains all printer details
|
||||
/// including connection configuration and operational status.
|
||||
/// </summary>
|
||||
public class PrinterResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the printer.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Current operational status (Idle, Printing, Offline, Error, Paused).</summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable name (e.g., "Bambu X1C #1", "Elegoo Centauri").</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Manufacturer/brand (e.g., "Bambu Lab", "Elegoo").</summary>
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Model name (e.g., "X1 Carbon", "Centauri Carbon").</summary>
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Printer hardware type ("Fdm" or "Resin").</summary>
|
||||
public string PrinterType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Connectivity protocol ("Mqtt" or "Moonraker").</summary>
|
||||
public string ConnectionType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hostname or IP address for printer connection.</summary>
|
||||
public string HostnameOrIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Port number for the connection (8883 for MQTT/TLS, 7125 for Moonraker).</summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>Whether the printer is currently active and available for jobs.</summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>Timestamp of the last status update received from the printer (UTC).</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last modified (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight response DTO for printer status. Optimized for polling
|
||||
/// and dashboard displays. For real-time updates, use the SignalR PrinterHub.
|
||||
/// </summary>
|
||||
public class PrinterStatusResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the printer.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Human-readable name of the printer.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Current operational status (Idle, Printing, Offline, Error, Paused).</summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Timestamp of the last status update received from the printer (UTC).</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
/// <summary>Whether the printer is currently active and available for jobs.</summary>
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for registering a new printer in the fleet.
|
||||
/// All string enums accept: PrinterType = "Fdm"|"Resin",
|
||||
/// ConnectionType = "Mqtt"|"Moonraker" (case-insensitive).
|
||||
/// </summary>
|
||||
public class CreatePrinterRequest
|
||||
{
|
||||
/// <summary>Human-readable name for the printer. Required, max 100 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Manufacturer/brand (e.g., "Bambu Lab", "Elegoo"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Manufacturer is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Manufacturer must be between 1 and 50 characters.")]
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Model name (e.g., "X1 Carbon", "Centauri Carbon"). Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Model is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Model must be between 1 and 50 characters.")]
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Printer hardware type: "Fdm" or "Resin". Defaults to "Fdm".</summary>
|
||||
[Required(ErrorMessage = "PrinterType is required.")]
|
||||
[RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")]
|
||||
public string PrinterType { get; set; } = "Fdm";
|
||||
|
||||
/// <summary>Connectivity protocol: "Mqtt" or "Moonraker". Defaults to "Mqtt".</summary>
|
||||
[Required(ErrorMessage = "ConnectionType is required.")]
|
||||
[RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")]
|
||||
public string ConnectionType { get; set; } = "Mqtt";
|
||||
|
||||
/// <summary>Hostname or IP address for printer connection. Required, max 253 characters.</summary>
|
||||
[Required(ErrorMessage = "HostnameOrIp is required.")]
|
||||
[StringLength(253, MinimumLength = 1, ErrorMessage = "HostnameOrIp must be between 1 and 253 characters.")]
|
||||
public string HostnameOrIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero.</summary>
|
||||
[Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")]
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>MQTT username for Bambu Lab authentication. Used only for MQTT connection type.</summary>
|
||||
[StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")]
|
||||
public string MqttUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>MQTT password for Bambu Lab authentication. Used only for MQTT connection type.</summary>
|
||||
[StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")]
|
||||
public string MqttPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether to use TLS for MQTT. Bambu Lab printers require TLS on port 8883.</summary>
|
||||
public bool MqttUseTls { get; set; }
|
||||
|
||||
/// <summary>Moonraker API key for Elegoo Centauri Carbon. Used only for Moonraker connection type.</summary>
|
||||
[StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the printer is active and available for jobs. Defaults to true.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing printer's configuration and connection info.
|
||||
/// All fields are provided on update (full replacement semantics).
|
||||
/// </summary>
|
||||
public class UpdatePrinterRequest
|
||||
{
|
||||
/// <summary>Human-readable name for the printer. Required, max 100 characters.</summary>
|
||||
[Required(ErrorMessage = "Name is required.")]
|
||||
[StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Manufacturer/brand. Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Manufacturer is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Manufacturer must be between 1 and 50 characters.")]
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Model name. Required, max 50 characters.</summary>
|
||||
[Required(ErrorMessage = "Model is required.")]
|
||||
[StringLength(50, MinimumLength = 1, ErrorMessage = "Model must be between 1 and 50 characters.")]
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Printer hardware type: "Fdm" or "Resin".</summary>
|
||||
[Required(ErrorMessage = "PrinterType is required.")]
|
||||
[RegularExpression("^(Fdm|Resin)$", ErrorMessage = "PrinterType must be 'Fdm' or 'Resin'.")]
|
||||
public string PrinterType { get; set; } = "Fdm";
|
||||
|
||||
/// <summary>Connectivity protocol: "Mqtt" or "Moonraker".</summary>
|
||||
[Required(ErrorMessage = "ConnectionType is required.")]
|
||||
[RegularExpression("^(Mqtt|Moonraker)$", ErrorMessage = "ConnectionType must be 'Mqtt' or 'Moonraker'.")]
|
||||
public string ConnectionType { get; set; } = "Mqtt";
|
||||
|
||||
/// <summary>Hostname or IP address. Required, max 253 characters.</summary>
|
||||
[Required(ErrorMessage = "HostnameOrIp is required.")]
|
||||
[StringLength(253, MinimumLength = 1, ErrorMessage = "HostnameOrIp must be between 1 and 253 characters.")]
|
||||
public string HostnameOrIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Port number. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker) if zero.</summary>
|
||||
[Range(0, 65535, ErrorMessage = "Port must be between 0 and 65535.")]
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>MQTT username. Used only for MQTT connection type.</summary>
|
||||
[StringLength(100, ErrorMessage = "MqttUsername must be at most 100 characters.")]
|
||||
public string MqttUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>MQTT password. Used only for MQTT connection type.</summary>
|
||||
[StringLength(200, ErrorMessage = "MqttPassword must be at most 200 characters.")]
|
||||
public string MqttPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether to use TLS for MQTT.</summary>
|
||||
public bool MqttUseTls { get; set; }
|
||||
|
||||
/// <summary>Moonraker API key. Used only for Moonraker connection type.</summary>
|
||||
[StringLength(100, ErrorMessage = "ApiKey must be at most 100 characters.")]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the printer is active and available for jobs.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
193
backend/API/DTOs/Spools/SpoolDtos.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Spools;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for Spool entity — the core inventory unit of Extrudex.
|
||||
/// Contains all spool details including denormalized material names for display.
|
||||
/// </summary>
|
||||
public class SpoolResponse
|
||||
{
|
||||
/// <summary>Unique identifier for the spool.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the base material.</summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Name of the base material (e.g., "PLA", "PETG").</summary>
|
||||
public string MaterialBaseName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the material finish.</summary>
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Name of the material finish (e.g., "Basic", "Matte").</summary>
|
||||
public string MaterialFinishName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Name of the material modifier (e.g., "Carbon Fiber"). Null if none.</summary>
|
||||
public string? MaterialModifierName { get; set; }
|
||||
|
||||
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker").</summary>
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name (e.g., "Fire Engine Red").</summary>
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000").</summary>
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full.</summary>
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams.</summary>
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in millimeters. Typically 1.75mm.</summary>
|
||||
public decimal FilamentDiameterMm { get; set; }
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique.</summary>
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Purchase price per spool. Null if not tracked.</summary>
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Date the spool was purchased or received.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is currently active and available.</summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was created (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp when this record was last updated (UTC).</summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating a new spool.
|
||||
/// All required fields must be provided. MaterialFinish is required — use "Basic" as the default.
|
||||
/// </summary>
|
||||
public class CreateSpoolRequest
|
||||
{
|
||||
/// <summary>Foreign key to the base material. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the material finish. Required — default is "Basic".</summary>
|
||||
[Required(ErrorMessage = "MaterialFinishId is required.")]
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Brand name (e.g., "Bambu Lab", "Polymaker"). Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "Brand is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name (e.g., "Fire Engine Red"). Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "ColorName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
|
||||
[Required(ErrorMessage = "ColorHex is required.")]
|
||||
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
|
||||
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "WeightTotalGrams is required.")]
|
||||
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
|
||||
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
|
||||
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in mm. Defaults to 1.75. Must be greater than zero.</summary>
|
||||
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
|
||||
public decimal FilamentDiameterMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "SpoolSerial is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
|
||||
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Optional purchase date. Must be a valid date if provided.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is active. Defaults to true.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an existing spool.
|
||||
/// All required fields must be provided for a full update.
|
||||
/// </summary>
|
||||
public class UpdateSpoolRequest
|
||||
{
|
||||
/// <summary>Foreign key to the base material. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialBaseId is required.")]
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the material finish. Required.</summary>
|
||||
[Required(ErrorMessage = "MaterialFinishId is required.")]
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Foreign key to the optional material modifier. Null if none applies.</summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>Brand name. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "Brand is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "Brand must be between 1 and 200 characters.")]
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Human-readable color name. Required, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "ColorName is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "ColorName must be between 1 and 200 characters.")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hex color code (e.g., "#FF0000"). Required, must be valid 7-char hex.</summary>
|
||||
[Required(ErrorMessage = "ColorHex is required.")]
|
||||
[RegularExpression(@"^#[0-9A-Fa-f]{6}$", ErrorMessage = "ColorHex must be a valid hex color code (e.g., #FF0000).")]
|
||||
[StringLength(7, MinimumLength = 7, ErrorMessage = "ColorHex must be exactly 7 characters (e.g., #FF0000).")]
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Total spool weight in grams when full. Must be greater than zero.</summary>
|
||||
[Required(ErrorMessage = "WeightTotalGrams is required.")]
|
||||
[Range(0.01, 100000, ErrorMessage = "Total weight must be between 0.01 and 100,000 grams.")]
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>Current remaining weight in grams. Must be non-negative.</summary>
|
||||
[Required(ErrorMessage = "WeightRemainingGrams is required.")]
|
||||
[Range(0, 100000, ErrorMessage = "Remaining weight must be between 0 and 100,000 grams.")]
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>Filament diameter in mm. Must be greater than zero.</summary>
|
||||
[Range(0.1, 10.0, ErrorMessage = "Filament diameter must be between 0.1 and 10.0 mm.")]
|
||||
public decimal FilamentDiameterMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>Manufacturer-assigned serial number. Must be unique, max 200 characters.</summary>
|
||||
[Required(ErrorMessage = "SpoolSerial is required.")]
|
||||
[StringLength(200, MinimumLength = 1, ErrorMessage = "SpoolSerial must be between 1 and 200 characters.")]
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional purchase price per spool. Must be non-negative if provided.</summary>
|
||||
[Range(0, 1000000, ErrorMessage = "Purchase price must be between 0 and 1,000,000.")]
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>Optional purchase date.</summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>Whether the spool is active.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
27
backend/API/DTOs/Spools/SpoolQueryDtos.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Extrudex.API.DTOs.Spools;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for filtering and paginating the spool list endpoint.
|
||||
/// All parameters are optional — defaults are applied when not provided.
|
||||
/// </summary>
|
||||
public class SpoolQueryParameters
|
||||
{
|
||||
/// <summary>Page number (1-based). Defaults to 1.</summary>
|
||||
[Range(1, int.MaxValue, ErrorMessage = "PageNumber must be at least 1.")]
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>Number of items per page. Defaults to 20, max 100.</summary>
|
||||
[Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100.")]
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>Optional filter by material base ID.</summary>
|
||||
public Guid? MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by material finish ID.</summary>
|
||||
public Guid? MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>Optional filter by active status. True = active only, False = inactive only.</summary>
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
31
backend/API/Hubs/IPrinterClient.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace Extrudex.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed client interface for the SignalR PrinterHub.
|
||||
/// Defines the methods that the server can invoke on connected clients
|
||||
/// to push real-time printer status updates.
|
||||
/// </summary>
|
||||
public interface IPrinterClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a full printer status update to all clients subscribed
|
||||
/// to a specific printer's group. Fired whenever a printer's
|
||||
/// operational status changes (e.g., Idle → Printing, Offline → Idle).
|
||||
/// </summary>
|
||||
/// <param name="printerId">The unique identifier of the printer that changed.</param>
|
||||
/// <param name="status">The new status value (e.g., "Idle", "Printing", "Offline", "Error", "Paused").</param>
|
||||
/// <param name="lastSeenAt">Timestamp (UTC) of when this status was last observed.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task PrinterStatusChanged(Guid printerId, string status, DateTime? lastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a lightweight heartbeat to confirm that a printer is still
|
||||
/// reachable and its connection is alive. Useful for dashboards that
|
||||
/// display online/offline indicators without requiring a full status payload.
|
||||
/// </summary>
|
||||
/// <param name="printerId">The unique identifier of the printer.</param>
|
||||
/// <param name="isActive">Whether the printer is currently active and available for jobs.</param>
|
||||
/// <param name="lastSeenAt">Timestamp (UTC) of the last telemetry received from the printer.</param>
|
||||
/// <returns>A Task that completes when the client has processed the heartbeat.</returns>
|
||||
Task PrinterHeartbeat(Guid printerId, bool isActive, DateTime? lastSeenAt);
|
||||
}
|
||||
137
backend/API/Hubs/PrinterHub.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Extrudex.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for real-time printer status updates.
|
||||
///
|
||||
/// Clients connect to this hub to receive push notifications when
|
||||
/// a printer's status changes (e.g., Idle → Printing, Offline → Idle)
|
||||
/// or when a heartbeat confirms the printer is still reachable.
|
||||
///
|
||||
/// <para>Usage flow:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Client connects to /hubs/printer</item>
|
||||
/// <item>Client calls <see cref="JoinPrinterGroup"/> with a printer ID</item>
|
||||
/// <item>Server adds the connection to a SignalR group named after the printer ID</item>
|
||||
/// <item>When the backend detects a status change, it calls
|
||||
/// <see cref="PrinterHubExtensions.PushPrinterStatusAsync"/>
|
||||
/// which broadcasts to all subscribers of that printer</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Group naming: <c>printer:{printerId}</c> (lowercase GUID).</para>
|
||||
///
|
||||
/// <para>Typed client: <see cref="IPrinterClient"/> — all server-to-client
|
||||
/// calls go through this interface for compile-time safety.</para>
|
||||
/// </summary>
|
||||
public class PrinterHub : Hub<IPrinterClient>
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the calling connection to the SignalR group for a specific printer.
|
||||
/// Once joined, the client will receive all status updates and heartbeats
|
||||
/// for that printer until it disconnects or calls <see cref="LeavePrinterGroup"/>.
|
||||
/// </summary>
|
||||
/// <param name="printerId">
|
||||
/// The unique identifier of the printer to subscribe to.
|
||||
/// The GUID is normalized to lowercase for consistent group naming.
|
||||
/// </param>
|
||||
/// <exception cref="HubException">
|
||||
/// Thrown if <paramref name="printerId"/> cannot be parsed as a valid GUID.
|
||||
/// </exception>
|
||||
public async Task JoinPrinterGroup(Guid printerId)
|
||||
{
|
||||
var groupName = PrinterGroupName(printerId);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from the SignalR group for a specific printer.
|
||||
/// After leaving, the client will no longer receive updates for that printer.
|
||||
/// </summary>
|
||||
/// <param name="printerId">
|
||||
/// The unique identifier of the printer to unsubscribe from.
|
||||
/// </param>
|
||||
public async Task LeavePrinterGroup(Guid printerId)
|
||||
{
|
||||
var groupName = PrinterGroupName(printerId);
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
|
||||
/// SignalR automatically removes disconnected connections from all groups,
|
||||
/// so no manual cleanup is required here.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
// SignalR automatically removes the connection from all groups on disconnect.
|
||||
// No manual cleanup needed.
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SignalR group name for a given printer ID.
|
||||
/// Format: <c>printer:{printerId}</c> (lowercase to avoid case-sensitivity issues).
|
||||
/// </summary>
|
||||
/// <param name="printerId">The unique identifier of the printer.</param>
|
||||
/// <returns>A consistent, lowercase group name string.</returns>
|
||||
internal static string PrinterGroupName(Guid printerId) =>
|
||||
$"printer:{printerId.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for pushing real-time printer updates through
|
||||
/// the <see cref="IHubContext{T}"/> of <see cref="PrinterHub"/>.
|
||||
///
|
||||
/// These methods are intended to be called from background services
|
||||
/// (e.g., MQTT message handlers, Moonraker pollers) or other
|
||||
/// server-side code that detects a printer state change.
|
||||
/// </summary>
|
||||
public static class PrinterHubExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a printer status change to all clients subscribed to
|
||||
/// the given printer's SignalR group.
|
||||
///
|
||||
/// Call this from any background service or controller when a printer's
|
||||
/// operational status changes (e.g., a Bambu Lab MQTT message reports
|
||||
/// the printer started printing, or a Moonraker poller detects an error).
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="printerId">The unique identifier of the printer that changed.</param>
|
||||
/// <param name="status">The new status string (e.g., "Idle", "Printing", "Offline").</param>
|
||||
/// <param name="lastSeenAt">Timestamp (UTC) of when the status was observed, or null if unknown.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushPrinterStatusAsync(
|
||||
this IHubContext<PrinterHub, IPrinterClient> hubContext,
|
||||
Guid printerId,
|
||||
string status,
|
||||
DateTime? lastSeenAt = null)
|
||||
{
|
||||
var groupName = PrinterHub.PrinterGroupName(printerId);
|
||||
await hubContext.Clients.Group(groupName)
|
||||
.PrinterStatusChanged(printerId, status, lastSeenAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a heartbeat signal to all clients subscribed to the given
|
||||
/// printer's SignalR group. Use this for lightweight "still alive"
|
||||
/// notifications that don't require a full status payload.
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="printerId">The unique identifier of the printer.</param>
|
||||
/// <param name="isActive">Whether the printer is currently active and accepting jobs.</param>
|
||||
/// <param name="lastSeenAt">Timestamp (UTC) of the last telemetry from the printer, or null.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushPrinterHeartbeatAsync(
|
||||
this IHubContext<PrinterHub, IPrinterClient> hubContext,
|
||||
Guid printerId,
|
||||
bool isActive,
|
||||
DateTime? lastSeenAt = null)
|
||||
{
|
||||
var groupName = PrinterHub.PrinterGroupName(printerId);
|
||||
await hubContext.Clients.Group(groupName)
|
||||
.PrinterHeartbeat(printerId, isActive, lastSeenAt);
|
||||
}
|
||||
}
|
||||
100
backend/API/Validators/MaterialValidators.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Extrudex.API.DTOs.Materials;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Extrudex.API.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a MaterialBase.
|
||||
/// </summary>
|
||||
public class CreateMaterialBaseRequestValidator : AbstractValidator<CreateMaterialBaseRequest>
|
||||
{
|
||||
public CreateMaterialBaseRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Material name is required.")
|
||||
.MaximumLength(50).WithMessage("Material name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.DensityGperCm3)
|
||||
.GreaterThan(0).WithMessage("Density must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a MaterialBase.
|
||||
/// </summary>
|
||||
public class UpdateMaterialBaseRequestValidator : AbstractValidator<UpdateMaterialBaseRequest>
|
||||
{
|
||||
public UpdateMaterialBaseRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Material name is required.")
|
||||
.MaximumLength(50).WithMessage("Material name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.DensityGperCm3)
|
||||
.GreaterThan(0).WithMessage("Density must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a MaterialFinish.
|
||||
/// </summary>
|
||||
public class CreateMaterialFinishRequestValidator : AbstractValidator<CreateMaterialFinishRequest>
|
||||
{
|
||||
public CreateMaterialFinishRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Finish name is required.")
|
||||
.MaximumLength(50).WithMessage("Finish name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a MaterialFinish.
|
||||
/// </summary>
|
||||
public class UpdateMaterialFinishRequestValidator : AbstractValidator<UpdateMaterialFinishRequest>
|
||||
{
|
||||
public UpdateMaterialFinishRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Finish name is required.")
|
||||
.MaximumLength(50).WithMessage("Finish name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a MaterialModifier.
|
||||
/// </summary>
|
||||
public class CreateMaterialModifierRequestValidator : AbstractValidator<CreateMaterialModifierRequest>
|
||||
{
|
||||
public CreateMaterialModifierRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Modifier name is required.")
|
||||
.MaximumLength(50).WithMessage("Modifier name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a MaterialModifier.
|
||||
/// </summary>
|
||||
public class UpdateMaterialModifierRequestValidator : AbstractValidator<UpdateMaterialModifierRequest>
|
||||
{
|
||||
public UpdateMaterialModifierRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Modifier name is required.")
|
||||
.MaximumLength(50).WithMessage("Modifier name must not exceed 50 characters.");
|
||||
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
}
|
||||
}
|
||||
106
backend/API/Validators/PrintJobValidators.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using Extrudex.API.DTOs.PrintJobs;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Extrudex.API.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a PrintJob.
|
||||
/// </summary>
|
||||
public class CreatePrintJobRequestValidator : AbstractValidator<CreatePrintJobRequest>
|
||||
{
|
||||
private static readonly string[] ValidDataSources = { "Mqtt", "Moonraker", "Manual" };
|
||||
|
||||
public CreatePrintJobRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.PrinterId)
|
||||
.NotEmpty().WithMessage("PrinterId is required.");
|
||||
|
||||
RuleFor(x => x.SpoolId)
|
||||
.NotEmpty().WithMessage("SpoolId is required.");
|
||||
|
||||
RuleFor(x => x.PrintName)
|
||||
.NotEmpty().WithMessage("PrintName is required.")
|
||||
.MaximumLength(200).WithMessage("PrintName must not exceed 200 characters.");
|
||||
|
||||
RuleFor(x => x.MmExtruded)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("MmExtruded must be non-negative.");
|
||||
|
||||
RuleFor(x => x.GramsDerived)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("GramsDerived must be non-negative.");
|
||||
|
||||
RuleFor(x => x.DataSource)
|
||||
.Must(x => ValidDataSources.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("DataSource must be 'Mqtt', 'Moonraker', or 'Manual'.");
|
||||
|
||||
RuleFor(x => x.FilamentDiameterAtPrintMm)
|
||||
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
|
||||
|
||||
RuleFor(x => x.MaterialDensityAtPrint)
|
||||
.GreaterThan(0).WithMessage("Material density must be greater than zero.");
|
||||
|
||||
When(x => x.GcodeFilePath != null, () =>
|
||||
{
|
||||
RuleFor(x => x.GcodeFilePath!)
|
||||
.MaximumLength(500).WithMessage("G-code file path must not exceed 500 characters.");
|
||||
});
|
||||
|
||||
When(x => x.Notes != null, () =>
|
||||
{
|
||||
RuleFor(x => x.Notes!)
|
||||
.MaximumLength(2000).WithMessage("Notes must not exceed 2000 characters.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a PrintJob.
|
||||
/// </summary>
|
||||
public class UpdatePrintJobRequestValidator : AbstractValidator<UpdatePrintJobRequest>
|
||||
{
|
||||
public UpdatePrintJobRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.PrinterId)
|
||||
.NotEmpty().WithMessage("PrinterId is required.");
|
||||
|
||||
RuleFor(x => x.SpoolId)
|
||||
.NotEmpty().WithMessage("SpoolId is required.");
|
||||
|
||||
RuleFor(x => x.PrintName)
|
||||
.NotEmpty().WithMessage("PrintName is required.")
|
||||
.MaximumLength(200).WithMessage("PrintName must not exceed 200 characters.");
|
||||
|
||||
RuleFor(x => x.MmExtruded)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("MmExtruded must be non-negative.");
|
||||
|
||||
RuleFor(x => x.GramsDerived)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("GramsDerived must be non-negative.");
|
||||
|
||||
When(x => x.GcodeFilePath != null, () =>
|
||||
{
|
||||
RuleFor(x => x.GcodeFilePath!)
|
||||
.MaximumLength(500).WithMessage("G-code file path must not exceed 500 characters.");
|
||||
});
|
||||
|
||||
When(x => x.Notes != null, () =>
|
||||
{
|
||||
RuleFor(x => x.Notes!)
|
||||
.MaximumLength(2000).WithMessage("Notes must not exceed 2000 characters.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a PrintJob status.
|
||||
/// </summary>
|
||||
public class UpdatePrintJobStatusRequestValidator : AbstractValidator<UpdatePrintJobStatusRequest>
|
||||
{
|
||||
private static readonly string[] ValidStatuses = { "Queued", "Printing", "Completed", "Cancelled", "Failed" };
|
||||
|
||||
public UpdatePrintJobStatusRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status)
|
||||
.NotEmpty().WithMessage("Status is required.")
|
||||
.Must(x => ValidStatuses.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("Status must be one of: Queued, Printing, Completed, Cancelled, Failed.");
|
||||
}
|
||||
}
|
||||
82
backend/API/Validators/PrinterValidators.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Extrudex.API.DTOs.Printers;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Extrudex.API.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a Printer.
|
||||
/// </summary>
|
||||
public class CreatePrinterRequestValidator : AbstractValidator<CreatePrinterRequest>
|
||||
{
|
||||
private static readonly string[] ValidPrinterTypes = { "Fdm", "Resin" };
|
||||
private static readonly string[] ValidConnectionTypes = { "Mqtt", "Moonraker" };
|
||||
|
||||
public CreatePrinterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Printer name is required.")
|
||||
.MaximumLength(100).WithMessage("Printer name must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.Manufacturer)
|
||||
.NotEmpty().WithMessage("Manufacturer is required.")
|
||||
.MaximumLength(100).WithMessage("Manufacturer must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.Model)
|
||||
.NotEmpty().WithMessage("Model is required.")
|
||||
.MaximumLength(100).WithMessage("Model must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.PrinterType)
|
||||
.Must(x => ValidPrinterTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("PrinterType must be 'Fdm' or 'Resin'.");
|
||||
|
||||
RuleFor(x => x.ConnectionType)
|
||||
.Must(x => ValidConnectionTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("ConnectionType must be 'Mqtt' or 'Moonraker'.");
|
||||
|
||||
RuleFor(x => x.HostnameOrIp)
|
||||
.NotEmpty().WithMessage("HostnameOrIp is required.")
|
||||
.MaximumLength(255).WithMessage("HostnameOrIp must not exceed 255 characters.");
|
||||
|
||||
RuleFor(x => x.Port)
|
||||
.InclusiveBetween(1, 65535).WithMessage("Port must be between 1 and 65535.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a Printer.
|
||||
/// </summary>
|
||||
public class UpdatePrinterRequestValidator : AbstractValidator<UpdatePrinterRequest>
|
||||
{
|
||||
private static readonly string[] ValidPrinterTypes = { "Fdm", "Resin" };
|
||||
private static readonly string[] ValidConnectionTypes = { "Mqtt", "Moonraker" };
|
||||
|
||||
public UpdatePrinterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Printer name is required.")
|
||||
.MaximumLength(100).WithMessage("Printer name must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.Manufacturer)
|
||||
.NotEmpty().WithMessage("Manufacturer is required.")
|
||||
.MaximumLength(100).WithMessage("Manufacturer must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.Model)
|
||||
.NotEmpty().WithMessage("Model is required.")
|
||||
.MaximumLength(100).WithMessage("Model must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.PrinterType)
|
||||
.Must(x => ValidPrinterTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("PrinterType must be 'Fdm' or 'Resin'.");
|
||||
|
||||
RuleFor(x => x.ConnectionType)
|
||||
.Must(x => ValidConnectionTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage("ConnectionType must be 'Mqtt' or 'Moonraker'.");
|
||||
|
||||
RuleFor(x => x.HostnameOrIp)
|
||||
.NotEmpty().WithMessage("HostnameOrIp is required.")
|
||||
.MaximumLength(255).WithMessage("HostnameOrIp must not exceed 255 characters.");
|
||||
|
||||
RuleFor(x => x.Port)
|
||||
.InclusiveBetween(1, 65535).WithMessage("Port must be between 1 and 65535.");
|
||||
}
|
||||
}
|
||||
96
backend/API/Validators/SpoolValidators.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Extrudex.API.DTOs.Spools;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Extrudex.API.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for creating a Spool.
|
||||
/// </summary>
|
||||
public class CreateSpoolRequestValidator : AbstractValidator<CreateSpoolRequest>
|
||||
{
|
||||
public CreateSpoolRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
|
||||
RuleFor(x => x.MaterialFinishId)
|
||||
.NotEmpty().WithMessage("MaterialFinishId is required.");
|
||||
|
||||
RuleFor(x => x.Brand)
|
||||
.NotEmpty().WithMessage("Brand is required.")
|
||||
.MaximumLength(100).WithMessage("Brand must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.ColorName)
|
||||
.NotEmpty().WithMessage("ColorName is required.")
|
||||
.MaximumLength(100).WithMessage("ColorName must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.ColorHex)
|
||||
.NotEmpty().WithMessage("ColorHex is required.")
|
||||
.Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000).");
|
||||
|
||||
RuleFor(x => x.WeightTotalGrams)
|
||||
.GreaterThan(0).WithMessage("Total weight must be greater than zero.");
|
||||
|
||||
RuleFor(x => x.WeightRemainingGrams)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative.");
|
||||
|
||||
RuleFor(x => x.FilamentDiameterMm)
|
||||
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
|
||||
|
||||
RuleFor(x => x.SpoolSerial)
|
||||
.NotEmpty().WithMessage("SpoolSerial is required.")
|
||||
.MaximumLength(100).WithMessage("SpoolSerial must not exceed 100 characters.");
|
||||
|
||||
When(x => x.PurchasePrice.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.PurchasePrice!.Value)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for updating a Spool.
|
||||
/// </summary>
|
||||
public class UpdateSpoolRequestValidator : AbstractValidator<UpdateSpoolRequest>
|
||||
{
|
||||
public UpdateSpoolRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.MaterialBaseId)
|
||||
.NotEmpty().WithMessage("MaterialBaseId is required.");
|
||||
|
||||
RuleFor(x => x.MaterialFinishId)
|
||||
.NotEmpty().WithMessage("MaterialFinishId is required.");
|
||||
|
||||
RuleFor(x => x.Brand)
|
||||
.NotEmpty().WithMessage("Brand is required.")
|
||||
.MaximumLength(100).WithMessage("Brand must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.ColorName)
|
||||
.NotEmpty().WithMessage("ColorName is required.")
|
||||
.MaximumLength(100).WithMessage("ColorName must not exceed 100 characters.");
|
||||
|
||||
RuleFor(x => x.ColorHex)
|
||||
.NotEmpty().WithMessage("ColorHex is required.")
|
||||
.Matches(@"^#[0-9A-Fa-f]{6}$").WithMessage("ColorHex must be a valid hex color code (e.g., #FF0000).");
|
||||
|
||||
RuleFor(x => x.WeightTotalGrams)
|
||||
.GreaterThan(0).WithMessage("Total weight must be greater than zero.");
|
||||
|
||||
RuleFor(x => x.WeightRemainingGrams)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Remaining weight must be non-negative.");
|
||||
|
||||
RuleFor(x => x.FilamentDiameterMm)
|
||||
.GreaterThan(0).WithMessage("Filament diameter must be greater than zero.");
|
||||
|
||||
RuleFor(x => x.SpoolSerial)
|
||||
.NotEmpty().WithMessage("SpoolSerial is required.")
|
||||
.MaximumLength(100).WithMessage("SpoolSerial must not exceed 100 characters.");
|
||||
|
||||
When(x => x.PurchasePrice.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.PurchasePrice!.Value)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Purchase price must be non-negative.");
|
||||
});
|
||||
}
|
||||
}
|
||||
19
backend/Domain/Base/AuditableEntity.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Extrudex.Domain.Base;
|
||||
|
||||
/// <summary>
|
||||
/// Base entity providing automatic audit timestamp tracking and a primary key.
|
||||
/// All domain entities that require created/updated timestamps should inherit from this class.
|
||||
/// Inherits Id from BaseEntity, so subclasses have both identity and audit columns.
|
||||
/// </summary>
|
||||
public abstract class AuditableEntity : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamp indicating when this entity was first created (UTC).
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp indicating when this entity was last modified (UTC).
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
12
backend/Domain/Base/BaseEntity.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Extrudex.Domain.Base;
|
||||
|
||||
/// <summary>
|
||||
/// Base entity providing a primary key identifier.
|
||||
/// </summary>
|
||||
public abstract class BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the entity.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
}
|
||||
41
backend/Domain/Entities/AmsSlot.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single slot within an AMS unit. Each slot can hold one spool
|
||||
/// and tracks the tray index, remaining weight, and which spool is loaded.
|
||||
/// </summary>
|
||||
public class AmsSlot : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// The 1-based tray/slot index within the AMS unit (1-4 per unit).
|
||||
/// </summary>
|
||||
public int TrayIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the AMS unit this slot belongs to.
|
||||
/// </summary>
|
||||
public Guid AmsUnitId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the parent AMS unit.
|
||||
/// </summary>
|
||||
public AmsUnit AmsUnit { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the spool currently loaded in this slot. Null if empty.
|
||||
/// </summary>
|
||||
public Guid? SpoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the spool currently loaded in this slot. Null if no spool is loaded.
|
||||
/// </summary>
|
||||
public Spool? Spool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining filament weight in grams as reported by the AMS.
|
||||
/// Bambu Lab AMS reports remaining weight per tray.
|
||||
/// </summary>
|
||||
public decimal? RemainingWeightG { get; set; }
|
||||
}
|
||||
30
backend/Domain/Entities/AmsUnit.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an AMS (Automatic Material System) unit installed on a Bambu Lab printer.
|
||||
/// Each AMS unit contains multiple slots that hold spools.
|
||||
/// </summary>
|
||||
public class AmsUnit : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// The 1-based index of this AMS unit on the printer (e.g., AMS 1, AMS 2).
|
||||
/// </summary>
|
||||
public int UnitIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the parent Printer this AMS unit is installed on.
|
||||
/// </summary>
|
||||
public Guid PrinterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the parent Printer.
|
||||
/// </summary>
|
||||
public Printer Printer { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of slots in this AMS unit.
|
||||
/// </summary>
|
||||
public ICollection<AmsSlot> Slots { get; set; } = new List<AmsSlot>();
|
||||
}
|
||||
36
backend/Domain/Entities/MaterialBase.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Base polymer/material type. This is a lookup table enforcing consistent
|
||||
/// material naming across all spools. Free-text material names are not allowed.
|
||||
/// </summary>
|
||||
public class MaterialBase : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable name of the base material (e.g., "PLA", "PETG", "ABS").
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Density of the material in g/cm³ (g/mL). Used for deriving grams consumed
|
||||
/// from mm extruded: grams = mm × cross_section_area × density.
|
||||
/// </summary>
|
||||
public decimal DensityGperCm3 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of finishes available for this material base.
|
||||
/// </summary>
|
||||
public ICollection<MaterialFinish> Finishes { get; set; } = new List<MaterialFinish>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of modifiers applicable to this material base.
|
||||
/// </summary>
|
||||
public ICollection<MaterialModifier> Modifiers { get; set; } = new List<MaterialModifier>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of spools made from this material base.
|
||||
/// </summary>
|
||||
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
|
||||
}
|
||||
30
backend/Domain/Entities/MaterialFinish.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Surface finish descriptor for a material. This is REQUIRED on every spool
|
||||
/// record. The default value is "Basic" (not "Standard").
|
||||
/// </summary>
|
||||
public class MaterialFinish : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable name of the finish (e.g., "Basic", "Matte", "Silk", "Glitter").
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the parent MaterialBase. A finish belongs to exactly one base material.
|
||||
/// </summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the parent MaterialBase.
|
||||
/// </summary>
|
||||
public MaterialBase MaterialBase { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of spools with this finish.
|
||||
/// </summary>
|
||||
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
|
||||
}
|
||||
30
backend/Domain/Entities/MaterialModifier.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Optional modifier/additive for a material (e.g., "Carbon Fiber", "Glass Fiber",
|
||||
/// "Wood Fill", "Glow-in-the-Dark"). Not every spool has a modifier.
|
||||
/// </summary>
|
||||
public class MaterialModifier : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable name of the modifier (e.g., "Carbon Fiber", "Wood Fill").
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the parent MaterialBase. A modifier belongs to exactly one base material.
|
||||
/// </summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the parent MaterialBase.
|
||||
/// </summary>
|
||||
public MaterialBase MaterialBase { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of spools with this modifier.
|
||||
/// </summary>
|
||||
public ICollection<Spool> Spools { get; set; } = new List<Spool>();
|
||||
}
|
||||
100
backend/Domain/Entities/PrintJob.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using Extrudex.Domain.Base;
|
||||
using Extrudex.Domain.Enums;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single print job. Tracks which printer and spool were used,
|
||||
/// how much filament was consumed, and audit snapshots of material properties
|
||||
/// at the time of printing (to preserve COGS accuracy even if material data
|
||||
/// changes later).
|
||||
/// </summary>
|
||||
public class PrintJob : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Foreign key to the printer that executed this print job.
|
||||
/// </summary>
|
||||
public Guid PrinterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the printer that executed this print job.
|
||||
/// </summary>
|
||||
public Printer Printer { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the spool that provided filament for this print job.
|
||||
/// </summary>
|
||||
public Guid SpoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the spool that provided filament for this print job.
|
||||
/// </summary>
|
||||
public Spool Spool { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name or identifier for the print job.
|
||||
/// </summary>
|
||||
public string PrintName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path or filename of the G-code file being printed.
|
||||
/// </summary>
|
||||
public string? GcodeFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total millimeters of filament extruded during this print job.
|
||||
/// The primary input to the COGS derivation formula.
|
||||
/// </summary>
|
||||
public decimal MmExtruded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived grams consumed for this print, calculated as:
|
||||
/// mm_extruded × cross_section_area × material_density_at_print.
|
||||
/// </summary>
|
||||
public decimal GramsDerived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculated cost of goods sold (COGS) for this print job.
|
||||
/// Derived from grams consumed and the spool's purchase price.
|
||||
/// </summary>
|
||||
public decimal? CostPerPrint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the print job started (UTC).
|
||||
/// </summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the print job completed or failed (UTC).
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the print job.
|
||||
/// </summary>
|
||||
public JobStatus Status { get; set; } = JobStatus.Queued;
|
||||
|
||||
/// <summary>
|
||||
/// The source of the print job data (which integration path provided it).
|
||||
/// </summary>
|
||||
public DataSource DataSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit snapshot: the filament diameter (mm) recorded at the time of printing.
|
||||
/// Preserved so COGS calculations remain accurate even if the spool's
|
||||
/// diameter is later corrected.
|
||||
/// </summary>
|
||||
public decimal FilamentDiameterAtPrintMm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit snapshot: the material density (g/cm³) recorded at the time of printing.
|
||||
/// Preserved so COGS calculations remain accurate even if the material's
|
||||
/// density is later corrected.
|
||||
/// </summary>
|
||||
public decimal MaterialDensityAtPrint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional notes about the print job (e.g., "First layer adhesion issues").
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
97
backend/Domain/Entities/Printer.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Extrudex.Domain.Base;
|
||||
using Extrudex.Domain.Enums;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a 3D printer in the fleet. Stores connection details for
|
||||
/// MQTT (Bambu Lab) or Moonraker (Elegoo Centauri Carbon) integration.
|
||||
/// </summary>
|
||||
public class Printer : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Current operational status of the printer, updated via real-time telemetry.
|
||||
/// </summary>
|
||||
public PrinterStatus Status { get; set; } = PrinterStatus.Offline;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for the printer (e.g., "Bambu X1C #1", "Elegoo Centauri").
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Manufacturer/brand of the printer (e.g., "Bambu Lab", "Elegoo").
|
||||
/// </summary>
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Model name (e.g., "X1 Carbon", "Centauri Carbon").
|
||||
/// </summary>
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The hardware type of the printer (FDM or Resin).
|
||||
/// </summary>
|
||||
public PrinterType PrinterType { get; set; } = PrinterType.Fdm;
|
||||
|
||||
/// <summary>
|
||||
/// The connectivity protocol used by this printer (MQTT or Moonraker).
|
||||
/// </summary>
|
||||
public ConnectionType ConnectionType { get; set; } = ConnectionType.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Hostname or IP address for connecting to the printer.
|
||||
/// </summary>
|
||||
public string HostnameOrIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Port number for the printer connection. Defaults: 8883 (MQTT/TLS), 7125 (Moonraker).
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MQTT username for Bambu Lab printer authentication.
|
||||
/// Stored only for MQTT connection type printers.
|
||||
/// </summary>
|
||||
public string MqttUsername { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT password for Bambu Lab printer authentication.
|
||||
/// Stored only for MQTT connection type printers.
|
||||
/// </summary>
|
||||
public string MqttPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use TLS for the MQTT connection. Bambu Lab printers
|
||||
/// require TLS on port 8883.
|
||||
/// </summary>
|
||||
public bool MqttUseTls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Moonraker API key for Elegoo Centauri Carbon authentication.
|
||||
/// Stored only for Moonraker connection type printers.
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this printer is currently active and available for print jobs.
|
||||
/// Inactive printers are retained for historical records.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the last status update received from the printer (UTC).
|
||||
/// Used to detect stale connections.
|
||||
/// </summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of AMS units installed on this printer.
|
||||
/// </summary>
|
||||
public ICollection<AmsUnit> AmsUnits { get; set; } = new List<AmsUnit>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of print jobs executed on this printer.
|
||||
/// </summary>
|
||||
public ICollection<PrintJob> PrintJobs { get; set; } = new List<PrintJob>();
|
||||
}
|
||||
105
backend/Domain/Entities/Spool.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Extrudex.Domain.Base;
|
||||
|
||||
namespace Extrudex.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a physical spool of filament. Every spool must have a MaterialBase
|
||||
/// and MaterialFinish. MaterialModifier is optional.
|
||||
/// Spools are the core inventory unit and link to PrintJobs for COGS tracking.
|
||||
/// </summary>
|
||||
public class Spool : AuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Foreign key to the base material. Every spool must specify a material base.
|
||||
/// </summary>
|
||||
public Guid MaterialBaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the base material (e.g., PLA, PETG, ABS).
|
||||
/// </summary>
|
||||
public MaterialBase MaterialBase { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the material finish. REQUIRED on every spool — default is "Basic".
|
||||
/// </summary>
|
||||
public Guid MaterialFinishId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the material finish (e.g., Basic, Matte, Silk, Glitter).
|
||||
/// </summary>
|
||||
public MaterialFinish MaterialFinish { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the optional material modifier. Null if no modifier applies.
|
||||
/// </summary>
|
||||
public Guid? MaterialModifierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation to the optional material modifier (e.g., Carbon Fiber, Wood Fill).
|
||||
/// </summary>
|
||||
public MaterialModifier? MaterialModifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable brand name (e.g., "Bambu Lab", "Polymaker", "eSUN").
|
||||
/// </summary>
|
||||
public string Brand { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable color name (e.g., "Fire Engine Red", "Galaxy Black").
|
||||
/// </summary>
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Hex color code for the filament (e.g., "#FF0000" for red).
|
||||
/// Enables color-based filtering and visual identification.
|
||||
/// </summary>
|
||||
public string ColorHex { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total spool weight in grams when full (brand new, unopened).
|
||||
/// </summary>
|
||||
public decimal WeightTotalGrams { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current remaining weight in grams. Updated via AMS data or manual check-in.
|
||||
/// </summary>
|
||||
public decimal WeightRemainingGrams { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Filament diameter in millimeters. Typically 1.75mm for FDM printers.
|
||||
/// Used in the COGS derivation formula: grams = mm × cross_section_area × density.
|
||||
/// </summary>
|
||||
public decimal FilamentDiameterMm { get; set; } = 1.75m;
|
||||
|
||||
/// <summary>
|
||||
/// Manufacturer-assigned serial number for the spool. Used for barcode/QR scanning.
|
||||
/// Must be unique across all spools.
|
||||
/// </summary>
|
||||
public string SpoolSerial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase price per spool in the system currency. Used for COGS calculations.
|
||||
/// </summary>
|
||||
public decimal? PurchasePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date the spool was purchased or received.
|
||||
/// </summary>
|
||||
public DateTime? PurchaseDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the spool is currently active and available for use.
|
||||
/// Inactive spools are retained for historical COGS records.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of AMS slots where this spool is loaded.
|
||||
/// </summary>
|
||||
public ICollection<AmsSlot> AmsSlots { get; set; } = new List<AmsSlot>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation collection of print jobs that consumed filament from this spool.
|
||||
/// </summary>
|
||||
public ICollection<PrintJob> PrintJobs { get; set; } = new List<PrintJob>();
|
||||
}
|
||||
13
backend/Domain/Enums/ConnectionType.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Describes how the backend communicates with a printer.
|
||||
/// </summary>
|
||||
public enum ConnectionType
|
||||
{
|
||||
/// <summary>Bambu Lab printers communicating via MQTT over TLS.</summary>
|
||||
Mqtt = 0,
|
||||
|
||||
/// <summary>Klipper-based printers (Elegoo) communicating via Moonraker REST/WebSocket.</summary>
|
||||
Moonraker = 1
|
||||
}
|
||||
16
backend/Domain/Enums/DataSource.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates where the print job data originated from.
|
||||
/// </summary>
|
||||
public enum DataSource
|
||||
{
|
||||
/// <summary>Data reported by a Bambu Lab printer via MQTT.</summary>
|
||||
Mqtt = 0,
|
||||
|
||||
/// <summary>Data reported by an Elegoo/Klipper printer via Moonraker.</summary>
|
||||
Moonraker = 1,
|
||||
|
||||
/// <summary>Manually entered by a user.</summary>
|
||||
Manual = 2
|
||||
}
|
||||
22
backend/Domain/Enums/JobStatus.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current lifecycle status of a print job.
|
||||
/// </summary>
|
||||
public enum JobStatus
|
||||
{
|
||||
/// <summary>Job has been created but not yet sent to the printer.</summary>
|
||||
Queued = 0,
|
||||
|
||||
/// <summary>Printer is actively printing this job.</summary>
|
||||
Printing = 1,
|
||||
|
||||
/// <summary>Job completed successfully.</summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>Job was cancelled by the user.</summary>
|
||||
Cancelled = 3,
|
||||
|
||||
/// <summary>Job failed due to an error.</summary>
|
||||
Failed = 4
|
||||
}
|
||||
32
backend/Domain/Enums/PrinterStatus.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current operational status of a printer.
|
||||
/// </summary>
|
||||
public enum PrinterStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Printer is online and idle, ready to accept jobs.
|
||||
/// </summary>
|
||||
Idle = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Printer is currently printing.
|
||||
/// </summary>
|
||||
Printing = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Printer is offline or unreachable.
|
||||
/// </summary>
|
||||
Offline = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Printer is in an error state.
|
||||
/// </summary>
|
||||
Error = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Printer is paused.
|
||||
/// </summary>
|
||||
Paused = 4
|
||||
}
|
||||
13
backend/Domain/Enums/PrinterType.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the type of 3D printer hardware.
|
||||
/// </summary>
|
||||
public enum PrinterType
|
||||
{
|
||||
/// <summary>FDM/FFF filament-based printer.</summary>
|
||||
Fdm = 0,
|
||||
|
||||
/// <summary>Resin-based SLA/DLP/LCD printer.</summary>
|
||||
Resin = 1
|
||||
}
|
||||
24
backend/Domain/Enums/QrResourceType.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Extrudex.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the resource types that support QR code generation.
|
||||
/// Each type maps to a distinct API route for QR code retrieval.
|
||||
/// </summary>
|
||||
public enum QrResourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// QR code for a filament spool — links to spool detail/scan view.
|
||||
/// </summary>
|
||||
Spool,
|
||||
|
||||
/// <summary>
|
||||
/// QR code for a printer — links to printer detail/monitor view.
|
||||
/// </summary>
|
||||
Printer,
|
||||
|
||||
/// <summary>
|
||||
/// QR code for a storage location — links to location inventory view.
|
||||
/// Reserved for future use when Location entities are introduced.
|
||||
/// </summary>
|
||||
Location
|
||||
}
|
||||
41
backend/Domain/Interfaces/IQrCodeService.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Extrudex.Domain.Enums;
|
||||
|
||||
namespace Extrudex.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for generating QR codes that encode deep links to
|
||||
/// Extrudex resources (spools, printers, locations). QR codes are
|
||||
/// high-contrast and optimized for small label printing.
|
||||
/// </summary>
|
||||
public interface IQrCodeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a PNG QR code image for the specified resource type and ID.
|
||||
/// The encoded URL points to the resource's detail page in the Extrudex frontend.
|
||||
/// </summary>
|
||||
/// <param name="resourceType">The type of resource (Spool, Printer, Location).</param>
|
||||
/// <param name="id">The unique identifier of the resource.</param>
|
||||
/// <param name="pixelsPerModule">
|
||||
/// Pixel density per QR module. Higher values produce larger images.
|
||||
/// Default (20) balances readability and label size.
|
||||
/// </param>
|
||||
/// <returns>A byte array containing the PNG image data.</returns>
|
||||
byte[] GeneratePng(QrResourceType resourceType, Guid id, int pixelsPerModule = 20);
|
||||
|
||||
/// <summary>
|
||||
/// Generates an SVG QR code image for the specified resource type and ID.
|
||||
/// SVG is resolution-independent and ideal for printing at any scale.
|
||||
/// </summary>
|
||||
/// <param name="resourceType">The type of resource (Spool, Printer, Location).</param>
|
||||
/// <param name="id">The unique identifier of the resource.</param>
|
||||
/// <returns>A string containing the SVG markup.</returns>
|
||||
string GenerateSvg(QrResourceType resourceType, Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the URL that will be encoded into the QR code for the given resource.
|
||||
/// </summary>
|
||||
/// <param name="resourceType">The type of resource.</param>
|
||||
/// <param name="id">The unique identifier of the resource.</param>
|
||||
/// <returns>The absolute URL to be encoded in the QR code.</returns>
|
||||
string GetResourceUrl(QrResourceType resourceType, Guid id);
|
||||
}
|
||||
21
backend/Extrudex.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Extrudex</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
18
backend/Extrudex.sln
Normal file
@@ -0,0 +1,18 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Extrudex", "Extrudex.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,50 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class AmsSlotConfiguration : BaseEntityConfiguration<AmsSlot>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<AmsSlot> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.TrayIndex)
|
||||
.HasColumnName("tray_index")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.AmsUnitId)
|
||||
.HasColumnName("ams_unit_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.SpoolId)
|
||||
.HasColumnName("spool_id");
|
||||
|
||||
builder.Property(e => e.RemainingWeightG)
|
||||
.HasColumnName("remaining_weight_g")
|
||||
.HasPrecision(10, 2);
|
||||
|
||||
// Unique index on (ams_unit_id, tray_index) — each slot position is unique within its unit
|
||||
builder.HasIndex(e => new { e.AmsUnitId, e.TrayIndex })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_ams_slots_ams_unit_id_tray_index");
|
||||
|
||||
// Index on spool_id for looking up which slot holds a given spool
|
||||
builder.HasIndex(e => e.SpoolId)
|
||||
.HasDatabaseName("ix_ams_slots_spool_id");
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(e => e.AmsUnit)
|
||||
.WithMany(e => e.Slots)
|
||||
.HasForeignKey(e => e.AmsUnitId)
|
||||
.HasConstraintName("fk_ams_slots_ams_unit")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(e => e.Spool)
|
||||
.WithMany(e => e.AmsSlots)
|
||||
.HasForeignKey(e => e.SpoolId)
|
||||
.HasConstraintName("fk_ams_slots_spool")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class AmsUnitConfiguration : BaseEntityConfiguration<AmsUnit>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<AmsUnit> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.UnitIndex)
|
||||
.HasColumnName("unit_index")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.PrinterId)
|
||||
.HasColumnName("printer_id")
|
||||
.IsRequired();
|
||||
|
||||
// Unique index on (printer_id, unit_index) — no two units on the same printer share an index
|
||||
builder.HasIndex(e => new { e.PrinterId, e.UnitIndex })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_ams_units_printer_id_unit_index");
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(e => e.Printer)
|
||||
.WithMany(e => e.AmsUnits)
|
||||
.HasForeignKey(e => e.PrinterId)
|
||||
.HasConstraintName("fk_ams_units_printer")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(e => e.Slots)
|
||||
.WithOne(e => e.AmsUnit)
|
||||
.HasForeignKey(e => e.AmsUnitId)
|
||||
.HasConstraintName("fk_ams_slots_ams_unit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Extrudex.Domain.Base;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Base configuration for all entities. Sets up common conventions:
|
||||
/// - Table names in snake_case
|
||||
/// - GUID primary keys stored as PostgreSQL UUID
|
||||
/// - Automatic timestamp columns in snake_case
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">The entity type to configure.</typeparam>
|
||||
public abstract class BaseEntityConfiguration<TEntity> : IEntityTypeConfiguration<TEntity>
|
||||
where TEntity : BaseEntity
|
||||
{
|
||||
public virtual void Configure(EntityTypeBuilder<TEntity> builder)
|
||||
{
|
||||
// Table name in snake_case
|
||||
builder.ToTable(ToSnakeCase(typeof(TEntity).Name));
|
||||
|
||||
// Primary key stored as UUID
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
// If the entity is auditable, configure the timestamp columns
|
||||
if (typeof(AuditableEntity).IsAssignableFrom(typeof(TEntity)))
|
||||
{
|
||||
ConfigureAuditColumns(builder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures audit timestamp columns (created_at, updated_at) for auditable entities.
|
||||
/// Uses string-based property names since the generic type constraint is BaseEntity
|
||||
/// and cannot be cast to AuditableEntity at compile time.
|
||||
/// </summary>
|
||||
private static void ConfigureAuditColumns(EntityTypeBuilder<TEntity> builder)
|
||||
{
|
||||
builder.Property<DateTime>("CreatedAt")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now() at time zone 'utc'");
|
||||
|
||||
builder.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now() at time zone 'utc'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts PascalCase or camelCase to snake_case.
|
||||
/// </summary>
|
||||
protected static string ToSnakeCase(string name)
|
||||
{
|
||||
return string.Concat(
|
||||
name.Select((ch, i) =>
|
||||
i > 0 && char.IsUpper(ch) && (char.IsLower(name[i - 1]) || (i + 1 < name.Length && char.IsLower(name[i + 1])))
|
||||
? "_" + ch
|
||||
: ch.ToString()))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class MaterialBaseConfiguration : BaseEntityConfiguration<MaterialBase>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<MaterialBase> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.HasColumnName("name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(e => e.DensityGperCm3)
|
||||
.HasColumnName("density_g_per_cm3")
|
||||
.HasPrecision(10, 4)
|
||||
.IsRequired();
|
||||
|
||||
// Unique index on material base name
|
||||
builder.HasIndex(e => e.Name)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_material_bases_name");
|
||||
|
||||
// Relationships
|
||||
builder.HasMany(e => e.Finishes)
|
||||
.WithOne(e => e.MaterialBase)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_material_finishes_material_base");
|
||||
|
||||
builder.HasMany(e => e.Modifiers)
|
||||
.WithOne(e => e.MaterialBase)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_material_modifiers_material_base");
|
||||
|
||||
builder.HasMany(e => e.Spools)
|
||||
.WithOne(e => e.MaterialBase)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_spools_material_base");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class MaterialFinishConfiguration : BaseEntityConfiguration<MaterialFinish>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<MaterialFinish> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.HasColumnName("name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(e => e.MaterialBaseId)
|
||||
.HasColumnName("material_base_id")
|
||||
.IsRequired();
|
||||
|
||||
// Unique index on (material_base_id, name) — each finish name is unique per base material
|
||||
builder.HasIndex(e => new { e.MaterialBaseId, e.Name })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_material_finishes_material_base_id_name");
|
||||
|
||||
// Relationship configured from MaterialBase side; navigation-only here
|
||||
builder.HasOne(e => e.MaterialBase)
|
||||
.WithMany(e => e.Finishes)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_material_finishes_material_base")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasMany(e => e.Spools)
|
||||
.WithOne(e => e.MaterialFinish)
|
||||
.HasForeignKey(e => e.MaterialFinishId)
|
||||
.HasConstraintName("fk_spools_material_finish");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class MaterialModifierConfiguration : BaseEntityConfiguration<MaterialModifier>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<MaterialModifier> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.HasColumnName("name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(e => e.MaterialBaseId)
|
||||
.HasColumnName("material_base_id")
|
||||
.IsRequired();
|
||||
|
||||
// Unique index on (material_base_id, name) — each modifier name is unique per base material
|
||||
builder.HasIndex(e => new { e.MaterialBaseId, e.Name })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_material_modifiers_material_base_id_name");
|
||||
|
||||
builder.HasOne(e => e.MaterialBase)
|
||||
.WithMany(e => e.Modifiers)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_material_modifiers_material_base")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasMany(e => e.Spools)
|
||||
.WithOne(e => e.MaterialModifier!)
|
||||
.HasForeignKey(e => e.MaterialModifierId)
|
||||
.HasConstraintName("fk_spools_material_modifier");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class PrintJobConfiguration : BaseEntityConfiguration<PrintJob>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<PrintJob> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.PrinterId)
|
||||
.HasColumnName("printer_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.SpoolId)
|
||||
.HasColumnName("spool_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.PrintName)
|
||||
.HasColumnName("print_name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(e => e.GcodeFilePath)
|
||||
.HasColumnName("gcode_file_path")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property(e => e.MmExtruded)
|
||||
.HasColumnName("mm_extruded")
|
||||
.HasPrecision(12, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.GramsDerived)
|
||||
.HasColumnName("grams_derived")
|
||||
.HasPrecision(10, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.CostPerPrint)
|
||||
.HasColumnName("cost_per_print")
|
||||
.HasPrecision(10, 4);
|
||||
|
||||
builder.Property(e => e.StartedAt)
|
||||
.HasColumnName("started_at");
|
||||
|
||||
builder.Property(e => e.CompletedAt)
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50)
|
||||
.HasDefaultValue(JobStatus.Queued)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.DataSource)
|
||||
.HasColumnName("data_source")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// Audit snapshots for COGS accuracy
|
||||
builder.Property(e => e.FilamentDiameterAtPrintMm)
|
||||
.HasColumnName("filament_diameter_at_print_mm")
|
||||
.HasPrecision(6, 3)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.MaterialDensityAtPrint)
|
||||
.HasColumnName("material_density_at_print")
|
||||
.HasPrecision(10, 4)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Notes)
|
||||
.HasColumnName("notes")
|
||||
.HasMaxLength(2000);
|
||||
|
||||
// Index on status for filtering active/completed jobs
|
||||
builder.HasIndex(e => e.Status)
|
||||
.HasDatabaseName("ix_print_jobs_status");
|
||||
|
||||
// Index on printer_id for querying jobs by printer
|
||||
builder.HasIndex(e => e.PrinterId)
|
||||
.HasDatabaseName("ix_print_jobs_printer_id");
|
||||
|
||||
// Index on spool_id for querying jobs by spool
|
||||
builder.HasIndex(e => e.SpoolId)
|
||||
.HasDatabaseName("ix_print_jobs_spool_id");
|
||||
|
||||
// Index on data_source for querying by integration path
|
||||
builder.HasIndex(e => e.DataSource)
|
||||
.HasDatabaseName("ix_print_jobs_data_source");
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(e => e.Printer)
|
||||
.WithMany(e => e.PrintJobs)
|
||||
.HasForeignKey(e => e.PrinterId)
|
||||
.HasConstraintName("fk_print_jobs_printer")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(e => e.Spool)
|
||||
.WithMany(e => e.PrintJobs)
|
||||
.HasForeignKey(e => e.SpoolId)
|
||||
.HasConstraintName("fk_print_jobs_spool")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Extrudex.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class PrinterConfiguration : BaseEntityConfiguration<Printer>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<Printer> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.HasColumnName("name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasColumnName("status")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50)
|
||||
.HasDefaultValue(PrinterStatus.Offline)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Manufacturer)
|
||||
.HasColumnName("manufacturer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.Model)
|
||||
.HasColumnName("model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.PrinterType)
|
||||
.HasColumnName("printer_type")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.ConnectionType)
|
||||
.HasColumnName("connection_type")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.HostnameOrIp)
|
||||
.HasColumnName("hostname_or_ip")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255);
|
||||
|
||||
builder.Property(e => e.Port)
|
||||
.HasColumnName("port")
|
||||
.IsRequired();
|
||||
|
||||
// MQTT credentials
|
||||
builder.Property(e => e.MqttUsername)
|
||||
.HasColumnName("mqtt_username")
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.MqttPassword)
|
||||
.HasColumnName("mqtt_password")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(e => e.MqttUseTls)
|
||||
.HasColumnName("mqtt_use_tls")
|
||||
.HasDefaultValue(false)
|
||||
.IsRequired();
|
||||
|
||||
// Moonraker API key
|
||||
builder.Property(e => e.ApiKey)
|
||||
.HasColumnName("api_key")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(e => e.IsActive)
|
||||
.HasColumnName("is_active")
|
||||
.HasDefaultValue(true)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.LastSeenAt)
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
// Index on status for filtering online/offline printers
|
||||
builder.HasIndex(e => e.Status)
|
||||
.HasDatabaseName("ix_printers_status");
|
||||
|
||||
// Index on printer_type for filtering by printer hardware type
|
||||
builder.HasIndex(e => e.PrinterType)
|
||||
.HasDatabaseName("ix_printers_printer_type");
|
||||
|
||||
// Index on connection for querying by protocol
|
||||
builder.HasIndex(e => e.ConnectionType)
|
||||
.HasDatabaseName("ix_printers_connection_type");
|
||||
|
||||
// Index on is_active for active printer queries
|
||||
builder.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("ix_printers_is_active");
|
||||
|
||||
// Relationships
|
||||
builder.HasMany(e => e.AmsUnits)
|
||||
.WithOne(e => e.Printer)
|
||||
.HasForeignKey(e => e.PrinterId)
|
||||
.HasConstraintName("fk_ams_units_printer");
|
||||
|
||||
builder.HasMany(e => e.PrintJobs)
|
||||
.WithOne(e => e.Printer)
|
||||
.HasForeignKey(e => e.PrinterId)
|
||||
.HasConstraintName("fk_print_jobs_printer");
|
||||
}
|
||||
}
|
||||
113
backend/Infrastructure/Data/Configurations/SpoolConfiguration.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data.Configurations;
|
||||
|
||||
public class SpoolConfiguration : BaseEntityConfiguration<Spool>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<Spool> builder)
|
||||
{
|
||||
base.Configure(builder);
|
||||
|
||||
builder.Property(e => e.MaterialBaseId)
|
||||
.HasColumnName("material_base_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.MaterialFinishId)
|
||||
.HasColumnName("material_finish_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.MaterialModifierId)
|
||||
.HasColumnName("material_modifier_id");
|
||||
|
||||
builder.Property(e => e.Brand)
|
||||
.HasColumnName("brand")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.ColorName)
|
||||
.HasColumnName("color_name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.ColorHex)
|
||||
.HasColumnName("color_hex")
|
||||
.IsRequired()
|
||||
.HasMaxLength(7); // "#RRGGBB" format
|
||||
|
||||
builder.Property(e => e.WeightTotalGrams)
|
||||
.HasColumnName("weight_total_grams")
|
||||
.HasPrecision(10, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.WeightRemainingGrams)
|
||||
.HasColumnName("weight_remaining_grams")
|
||||
.HasPrecision(10, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.FilamentDiameterMm)
|
||||
.HasColumnName("filament_diameter_mm")
|
||||
.HasPrecision(6, 3)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.SpoolSerial)
|
||||
.HasColumnName("spool_serial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.PurchasePrice)
|
||||
.HasColumnName("purchase_price")
|
||||
.HasPrecision(10, 2);
|
||||
|
||||
builder.Property(e => e.PurchaseDate)
|
||||
.HasColumnName("purchase_date");
|
||||
|
||||
builder.Property(e => e.IsActive)
|
||||
.HasColumnName("is_active")
|
||||
.HasDefaultValue(true)
|
||||
.IsRequired();
|
||||
|
||||
// Unique index on spool_serial — critical for barcode/QR scanning
|
||||
builder.HasIndex(e => e.SpoolSerial)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_spools_spool_serial");
|
||||
|
||||
// Index on material_base_id for spool filtering
|
||||
builder.HasIndex(e => e.MaterialBaseId)
|
||||
.HasDatabaseName("ix_spools_material_base_id");
|
||||
|
||||
// Index on is_active for active spool queries
|
||||
builder.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("ix_spools_is_active");
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(e => e.MaterialBase)
|
||||
.WithMany(e => e.Spools)
|
||||
.HasForeignKey(e => e.MaterialBaseId)
|
||||
.HasConstraintName("fk_spools_material_base")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(e => e.MaterialFinish)
|
||||
.WithMany(e => e.Spools)
|
||||
.HasForeignKey(e => e.MaterialFinishId)
|
||||
.HasConstraintName("fk_spools_material_finish")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(e => e.MaterialModifier)
|
||||
.WithMany(e => e.Spools)
|
||||
.HasForeignKey(e => e.MaterialModifierId)
|
||||
.HasConstraintName("fk_spools_material_modifier")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasMany(e => e.AmsSlots)
|
||||
.WithOne(e => e.Spool!)
|
||||
.HasForeignKey(e => e.SpoolId)
|
||||
.HasConstraintName("fk_ams_slots_spool");
|
||||
|
||||
builder.HasMany(e => e.PrintJobs)
|
||||
.WithOne(e => e.Spool)
|
||||
.HasForeignKey(e => e.SpoolId)
|
||||
.HasConstraintName("fk_print_jobs_spool");
|
||||
}
|
||||
}
|
||||
78
backend/Infrastructure/Data/ExtrudexDbContext.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Extrudex.Domain.Base;
|
||||
using Extrudex.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Main EF Core database context for the Extrudex system.
|
||||
/// Handles entity registration, snake_case naming, and automatic timestamp management.
|
||||
/// </summary>
|
||||
public class ExtrudexDbContext : DbContext
|
||||
{
|
||||
public ExtrudexDbContext(DbContextOptions<ExtrudexDbContext> options) : base(options) { }
|
||||
|
||||
// Lookup tables
|
||||
public DbSet<MaterialBase> MaterialBases => Set<MaterialBase>();
|
||||
public DbSet<MaterialFinish> MaterialFinishes => Set<MaterialFinish>();
|
||||
public DbSet<MaterialModifier> MaterialModifiers => Set<MaterialModifier>();
|
||||
|
||||
// Core entities
|
||||
public DbSet<Spool> Spools => Set<Spool>();
|
||||
public DbSet<Printer> Printers => Set<Printer>();
|
||||
public DbSet<AmsUnit> AmsUnits => Set<AmsUnit>();
|
||||
public DbSet<AmsSlot> AmsSlots => Set<AmsSlot>();
|
||||
public DbSet<PrintJob> PrintJobs => Set<PrintJob>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Apply all entity type configurations from the assembly
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ExtrudexDbContext).Assembly);
|
||||
|
||||
// Apply seed data
|
||||
modelBuilder.Entity<MaterialBase>().HasData(SeedData.MaterialBases);
|
||||
modelBuilder.Entity<MaterialFinish>().HasData(SeedData.MaterialFinishes);
|
||||
modelBuilder.Entity<MaterialModifier>().HasData(SeedData.MaterialModifiers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatically set UpdatedAt on auditable entities during SaveChanges.
|
||||
/// </summary>
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
SetAuditTimestamps();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(
|
||||
bool acceptAllChangesOnSuccess,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SetAuditTimestamps();
|
||||
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets UpdatedAt on all auditable entities that have been modified.
|
||||
/// Sets CreatedAt on all auditable entities that are being added.
|
||||
/// </summary>
|
||||
private void SetAuditTimestamps()
|
||||
{
|
||||
var entries = ChangeTracker.Entries<AuditableEntity>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = DateTime.UtcNow;
|
||||
entry.Entity.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
backend/Infrastructure/Data/Seed/SeedData.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Extrudex.Domain.Entities;
|
||||
|
||||
namespace Extrudex.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Static seed data for all material lookup tables.
|
||||
/// These are inserted via EF Core HasData during the initial migration.
|
||||
///
|
||||
/// IDs are deterministic GUIDs to ensure idempotent seed operations.
|
||||
/// MaterialFinish is REQUIRED on every spool — default value is "Basic" (not "Standard").
|
||||
/// </summary>
|
||||
public static class SeedData
|
||||
{
|
||||
// ─────────────────────────────────────────────
|
||||
// MaterialBase — deterministic GUIDs
|
||||
// ─────────────────────────────────────────────
|
||||
public static readonly Guid PlaId = Guid.Parse("10000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid PetgId = Guid.Parse("10000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid AbsId = Guid.Parse("10000000-0000-0000-0000-000000000003");
|
||||
public static readonly Guid AsaId = Guid.Parse("10000000-0000-0000-0000-000000000004");
|
||||
public static readonly Guid TpuId = Guid.Parse("10000000-0000-0000-0000-000000000005");
|
||||
public static readonly Guid NylonId = Guid.Parse("10000000-0000-0000-0000-000000000006");
|
||||
|
||||
public static readonly MaterialBase[] MaterialBases =
|
||||
[
|
||||
new() { Id = PlaId, Name = "PLA", DensityGperCm3 = 1.24m },
|
||||
new() { Id = PetgId, Name = "PETG", DensityGperCm3 = 1.27m },
|
||||
new() { Id = AbsId, Name = "ABS", DensityGperCm3 = 1.04m },
|
||||
new() { Id = AsaId, Name = "ASA", DensityGperCm3 = 1.07m },
|
||||
new() { Id = TpuId, Name = "TPU", DensityGperCm3 = 1.21m },
|
||||
new() { Id = NylonId, Name = "Nylon", DensityGperCm3 = 1.14m }
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// MaterialFinish — "Basic" is the default, NOT "Standard"
|
||||
// ─────────────────────────────────────────────
|
||||
public static readonly Guid PlaBasicId = Guid.Parse("20000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid PlaMatteId = Guid.Parse("20000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid PlaSilkId = Guid.Parse("20000000-0000-0000-0000-000000000003");
|
||||
public static readonly Guid PlaGlitterId = Guid.Parse("20000000-0000-0000-0000-000000000004");
|
||||
public static readonly Guid PlaMarbleId = Guid.Parse("20000000-0000-0000-0000-000000000005");
|
||||
public static readonly Guid PlaSparkleId = Guid.Parse("20000000-0000-0000-0000-000000000006");
|
||||
public static readonly Guid PetgBasicId = Guid.Parse("20000000-0000-0000-0000-000000000007");
|
||||
public static readonly Guid PetgMatteId = Guid.Parse("20000000-0000-0000-0000-000000000008");
|
||||
public static readonly Guid PetgSilkId = Guid.Parse("20000000-0000-0000-0000-000000000009");
|
||||
public static readonly Guid AbsBasicId = Guid.Parse("20000000-0000-0000-0000-000000000010");
|
||||
public static readonly Guid AbsMatteId = Guid.Parse("20000000-0000-0000-0000-000000000011");
|
||||
public static readonly Guid AsaBasicId = Guid.Parse("20000000-0000-0000-0000-000000000012");
|
||||
public static readonly Guid AsaMatteId = Guid.Parse("20000000-0000-0000-0000-000000000013");
|
||||
public static readonly Guid TpuBasicId = Guid.Parse("20000000-0000-0000-0000-000000000014");
|
||||
public static readonly Guid NylonBasicId = Guid.Parse("20000000-0000-0000-0000-000000000015");
|
||||
|
||||
public static readonly MaterialFinish[] MaterialFinishes =
|
||||
[
|
||||
// PLA finishes
|
||||
new() { Id = PlaBasicId, Name = "Basic", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaMatteId, Name = "Matte", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaSilkId, Name = "Silk", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaGlitterId, Name = "Glitter", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaMarbleId, Name = "Marble", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaSparkleId, Name = "Sparkle", MaterialBaseId = PlaId },
|
||||
|
||||
// PETG finishes
|
||||
new() { Id = PetgBasicId, Name = "Basic", MaterialBaseId = PetgId },
|
||||
new() { Id = PetgMatteId, Name = "Matte", MaterialBaseId = PetgId },
|
||||
new() { Id = PetgSilkId, Name = "Silk", MaterialBaseId = PetgId },
|
||||
|
||||
// ABS finishes
|
||||
new() { Id = AbsBasicId, Name = "Basic", MaterialBaseId = AbsId },
|
||||
new() { Id = AbsMatteId, Name = "Matte", MaterialBaseId = AbsId },
|
||||
|
||||
// ASA finishes
|
||||
new() { Id = AsaBasicId, Name = "Basic", MaterialBaseId = AsaId },
|
||||
new() { Id = AsaMatteId, Name = "Matte", MaterialBaseId = AsaId },
|
||||
|
||||
// TPU finishes
|
||||
new() { Id = TpuBasicId, Name = "Basic", MaterialBaseId = TpuId },
|
||||
|
||||
// Nylon finishes
|
||||
new() { Id = NylonBasicId, Name = "Basic", MaterialBaseId = NylonId }
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// MaterialModifier — optional additives
|
||||
// ─────────────────────────────────────────────
|
||||
public static readonly Guid PlaCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid PlaGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid PlaWoodFillId = Guid.Parse("30000000-0000-0000-0000-000000000003");
|
||||
public static readonly Guid PlaGlowInDarkId = Guid.Parse("30000000-0000-0000-0000-000000000004");
|
||||
public static readonly Guid PetgCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000005");
|
||||
public static readonly Guid PetgGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000006");
|
||||
public static readonly Guid AbsCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000007");
|
||||
public static readonly Guid AbsGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000008");
|
||||
public static readonly Guid AsaCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000009");
|
||||
public static readonly Guid NylonCarbonFiberId = Guid.Parse("30000000-0000-0000-0000-000000000010");
|
||||
public static readonly Guid NylonGlassFiberId = Guid.Parse("30000000-0000-0000-0000-000000000011");
|
||||
|
||||
public static readonly MaterialModifier[] MaterialModifiers =
|
||||
[
|
||||
// PLA modifiers
|
||||
new() { Id = PlaCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaGlassFiberId, Name = "Glass Fiber", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaWoodFillId, Name = "Wood Fill", MaterialBaseId = PlaId },
|
||||
new() { Id = PlaGlowInDarkId, Name = "Glow-in-the-Dark", MaterialBaseId = PlaId },
|
||||
|
||||
// PETG modifiers
|
||||
new() { Id = PetgCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = PetgId },
|
||||
new() { Id = PetgGlassFiberId, Name = "Glass Fiber", MaterialBaseId = PetgId },
|
||||
|
||||
// ABS modifiers
|
||||
new() { Id = AbsCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = AbsId },
|
||||
new() { Id = AbsGlassFiberId, Name = "Glass Fiber", MaterialBaseId = AbsId },
|
||||
|
||||
// ASA modifiers
|
||||
new() { Id = AsaCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = AsaId },
|
||||
|
||||
// Nylon modifiers
|
||||
new() { Id = NylonCarbonFiberId, Name = "Carbon Fiber", MaterialBaseId = NylonId },
|
||||
new() { Id = NylonGlassFiberId, Name = "Glass Fiber", MaterialBaseId = NylonId }
|
||||
];
|
||||
}
|
||||
67
backend/Infrastructure/Services/QrCodeService.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using Extrudex.Domain.Enums;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using QRCoder;
|
||||
|
||||
namespace Extrudex.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates high-contrast QR codes encoding deep links to Extrudex resources.
|
||||
/// Optimized for small label printing with dark modules on white background.
|
||||
/// Uses QRCoder library with ECC-level High for robust scanning on tiny labels.
|
||||
/// </summary>
|
||||
public class QrCodeService : IQrCodeService
|
||||
{
|
||||
private const string BaseUrl = "https://extrudex.app";
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] GeneratePng(QrResourceType resourceType, Guid id, int pixelsPerModule = 20)
|
||||
{
|
||||
var url = GetResourceUrl(resourceType, id);
|
||||
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var qrCodeData = qrGenerator.CreateQrCode(
|
||||
url,
|
||||
QRCodeGenerator.ECCLevel.H); // High error correction — critical for small labels
|
||||
|
||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||
return qrCode.GetGraphic(
|
||||
pixelsPerModule,
|
||||
darkColorRgba: new byte[] { 0, 0, 0, 255 }, // Pure black — maximum contrast
|
||||
lightColorRgba: new byte[] { 255, 255, 255, 255 }, // Pure white background
|
||||
drawQuietZones: true); // Quiet zones improve scan reliability
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateSvg(QrResourceType resourceType, Guid id)
|
||||
{
|
||||
var url = GetResourceUrl(resourceType, id);
|
||||
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var qrCodeData = qrGenerator.CreateQrCode(
|
||||
url,
|
||||
QRCodeGenerator.ECCLevel.H);
|
||||
|
||||
using var svgQrCode = new SvgQRCode(qrCodeData);
|
||||
return svgQrCode.GetGraphic(
|
||||
pixelsPerModule: 20,
|
||||
darkColorHex: "#000000", // Pure black — maximum contrast
|
||||
lightColorHex: "#FFFFFF", // Pure white background
|
||||
drawQuietZones: true,
|
||||
sizingMode: SvgQRCode.SizingMode.WidthHeightAttribute);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetResourceUrl(QrResourceType resourceType, Guid id)
|
||||
{
|
||||
var path = resourceType switch
|
||||
{
|
||||
QrResourceType.Spool => $"/spools/{id}",
|
||||
QrResourceType.Printer => $"/printers/{id}",
|
||||
QrResourceType.Location => $"/locations/{id}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(resourceType),
|
||||
resourceType, $"Unsupported QR resource type: {resourceType}")
|
||||
};
|
||||
|
||||
return $"{BaseUrl}{path}";
|
||||
}
|
||||
}
|
||||
103
backend/Program.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Reflection;
|
||||
using Extrudex.API.Hubs;
|
||||
using Extrudex.Domain.Interfaces;
|
||||
using Extrudex.Infrastructure.Data;
|
||||
using Extrudex.Infrastructure.Services;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── Database ───────────────────────────────────────────────
|
||||
// Connection string resolution (highest priority first):
|
||||
// 1. EXTRUDEX_DB_CONNECTION_STRING env var (Docker / production)
|
||||
// 2. Individual env vars: EXTRUDEX_DB_HOST, EXTRUDEX_DB_PORT, etc.
|
||||
// 3. appsettings.json ConnectionStrings:ExtrudexDb
|
||||
// 4. Hardcoded default for local dev
|
||||
var connectionString = Environment.GetEnvironmentVariable("EXTRUDEX_DB_CONNECTION_STRING")
|
||||
?? BuildConnectionStringFromEnvVars()
|
||||
?? builder.Configuration.GetConnectionString("ExtrudexDb")
|
||||
?? "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme";
|
||||
|
||||
builder.Services.AddDbContext<ExtrudexDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// ── API Services ───────────────────────────────────────────
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "Extrudex API",
|
||||
Version = "v1",
|
||||
Description = "Filament inventory and print tracking system"
|
||||
});
|
||||
|
||||
// Include XML doc comments in Swagger output
|
||||
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
c.IncludeXmlComments(xmlPath);
|
||||
}
|
||||
});
|
||||
|
||||
// ── QR Code Generation ──────────────────────────────────────
|
||||
builder.Services.AddSingleton<IQrCodeService, QrCodeService>();
|
||||
|
||||
// ── FluentValidation ──────────────────────────────────────
|
||||
// Registers all validators from the API assembly into DI.
|
||||
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// ── CORS (kiosk + remote browser) ─────────────────────────
|
||||
// AllowAnyOrigin disallows credentials by spec; this is fine for
|
||||
// REST API calls. SignalR WebSockets negotiate without credentials
|
||||
// by default, so no special CORS policy is needed. If browser clients
|
||||
// require credentials (cookies, auth headers), replace AllowAnyOrigin
|
||||
// with .WithOrigins(...) and add .AllowCredentials().
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SignalR (real-time printer updates) ────────────────────
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Middleware ──────────────────────────────────────────────
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// ── Hub Endpoints ───────────────────────────────────────────
|
||||
app.MapHub<PrinterHub>("/hubs/printer");
|
||||
|
||||
app.Run();
|
||||
|
||||
// Helper: builds a connection string from individual env vars.
|
||||
// Returns null if EXTRUDEX_DB_HOST is not set.
|
||||
static string? BuildConnectionStringFromEnvVars()
|
||||
{
|
||||
var host = Environment.GetEnvironmentVariable("EXTRUDEX_DB_HOST");
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
|
||||
var port = Environment.GetEnvironmentVariable("EXTRUDEX_DB_PORT") ?? "5432";
|
||||
var database = Environment.GetEnvironmentVariable("EXTRUDEX_DB_NAME") ?? "extrudex";
|
||||
var username = Environment.GetEnvironmentVariable("EXTRUDEX_DB_USER") ?? "extrudex";
|
||||
var password = Environment.GetEnvironmentVariable("EXTRUDEX_DB_PASSWORD") ?? "changeme";
|
||||
|
||||
return $"Host={host};Port={port};Database={database};Username={username};Password={password}";
|
||||
}
|
||||
12
backend/appsettings.Development.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Debug"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme"
|
||||
}
|
||||
}
|
||||
13
backend/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme"
|
||||
}
|
||||
}
|
||||
344
design/01-filament-inventory-list.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Filament Inventory List — Screen Specification
|
||||
|
||||
> **Screen ID:** FIL-001
|
||||
> **Source of Truth:** [Material Design 3](https://m3.material.io/)
|
||||
> **Tone:** Modern Industrial/Maker
|
||||
> **Theme:** Dark Mode, High-Contrast
|
||||
> **Last Updated:** 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Provide a single, scannable view of all filament spools in inventory. Users (shop operators) must be able to:
|
||||
|
||||
- Rapidly assess stock levels at a glance (which spools are low/critical).
|
||||
- Search by material type, brand, color, or internal tracking ID.
|
||||
- Filter by status (Available, In Use, Low Stock, Depleted).
|
||||
- Navigate to spool detail or initiate the Smart Intake workflow.
|
||||
|
||||
This is the **primary landing screen** for both the kiosk and the mobile PWA.
|
||||
|
||||
---
|
||||
|
||||
## 2. Screen Inventory
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Top App Bar | `md-top-app-bar` (medium) | Title + search toggle + intake FAB |
|
||||
| Search Bar | `md-search-bar` | Expandable on tap; collapses to icon on kiosk |
|
||||
| Filter Chips | `md-chip-set` (filter) | Status filters: All / Available / In Use / Low / Depleted |
|
||||
| Inventory List | `md-list` (3-line) | Spool cards as list items |
|
||||
| Low-Stock Badge | `md-badge` | Tonal badge on list items |
|
||||
| Extended FAB | `md-fab` (extended) | "Smart Intake" CTA |
|
||||
| Navigation Rail | `md-navigation-rail` | Kiosk only — Inventory / Printers / Jobs / Settings |
|
||||
| Bottom Navigation | `md-navigation-bar` | Mobile only — same destinations |
|
||||
| Empty State | Illustration + `md-text-button` | When no spools match filter |
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Specification
|
||||
|
||||
### Title
|
||||
**"Filament Inventory"** — displayed in the Top App Bar headline slot.
|
||||
|
||||
### Sections
|
||||
|
||||
#### A. Top App Bar (56dp mobile / 64dp kiosk)
|
||||
- **Leading:** Menu icon (kiosk) / Back arrow (mobile, if deep-linked)
|
||||
- **Title:** "Filament Inventory"
|
||||
- **Trailing actions:** Search icon (toggles search bar), Overflow menu (Sort by, Export)
|
||||
|
||||
#### B. Search Bar (0dp collapsed → 56dp expanded)
|
||||
- Triggered by search icon tap or pull-down gesture on mobile
|
||||
- Placeholder: "Search spools, brands, colors…"
|
||||
- Auto-focus on expand; dismiss on back/clear
|
||||
- Real-time filtering as user types
|
||||
|
||||
#### C. Filter Chip Row (52dp height, horizontal scroll)
|
||||
- Chips: `All` | `Available` | `In Use` | `Low Stock` | `Depleted`
|
||||
- Default selection: `All`
|
||||
- Multi-select disabled — single filter active at a time
|
||||
- Chip styling: Tonal variants per status color
|
||||
- Available: `green` tonal
|
||||
- In Use: `blue` tonal (primary)
|
||||
- Low Stock: `yellow` tonal (warning)
|
||||
- Depleted: `red` tonal (error)
|
||||
|
||||
#### D. Inventory List (flex, scrollable)
|
||||
- Each item is a **3-line list item** with leading/trailing elements:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ [Color Swatch] PLA Basic - Matte Black │
|
||||
│ Bambu Lab • Slot A3 • 842g left │
|
||||
│ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] badge │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ [Color Swatch] PETG Basic - Transparent │
|
||||
│ Polymaker • Shelf B2 • 210g left │
|
||||
│ ▓▓▓░░░░░░░░░ 21% [Critical] ⚠ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Leading:** Circular color swatch (40dp) — matches filament color
|
||||
- **Line 1:** Material name (MaterialBase + Finish + Modifier) — `titleMedium`, `onSurface`
|
||||
- **Line 2:** Brand • Location • Remaining weight — `bodyMedium`, `onSurfaceVariant`
|
||||
- **Line 3:** Linear progress indicator + percentage text — `labelSmall`, `onSurfaceVariant`
|
||||
- **Trailing:** Status badge (tonal chip) + chevron icon
|
||||
- **Divider:** Full-width between items
|
||||
|
||||
**Sort order (default):** Low stock first, then alphabetical by material name.
|
||||
|
||||
#### E. Extended FAB (Bottom-end, mobile; Bottom-end, kiosk)
|
||||
- Label: **"Smart Intake"** + `qr_code_scanner` icon
|
||||
- Container: `primaryContainer` color
|
||||
- On tap → navigates to Smart Intake Scan State
|
||||
|
||||
#### F. Navigation (Screen-level)
|
||||
- **Kiosk:** Navigation Rail on leading edge (80dp wide)
|
||||
- Destinations: Inventory (active), Printers, Jobs, Settings
|
||||
- **Mobile:** Bottom Navigation Bar (80dp height)
|
||||
- Same destinations
|
||||
|
||||
### Primary CTA
|
||||
**Smart Intake** (Extended FAB) — always visible, anchored bottom-end.
|
||||
|
||||
### Secondary Actions
|
||||
- Tap list item → Spool Detail View
|
||||
- Overflow → Sort options (Name A-Z, Name Z-A, Weight Low→High, Weight High→Low, Recently Used)
|
||||
- Overflow → Export inventory (CSV)
|
||||
|
||||
### Key Components
|
||||
- Search bar with real-time filtering
|
||||
- Filter chip set (single-select)
|
||||
- 3-line list items with progress indicators
|
||||
- Color swatch leading element
|
||||
- Status tonal badges
|
||||
- Extended FAB
|
||||
|
||||
### States
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| **Loading** | `md-circular-progress` centered, list skeleton (shimmer) |
|
||||
| **Empty (no spools)** | Spool icon illustration + "No spools in inventory" + "Add your first spool" text button |
|
||||
| **Empty (no filter matches)** | "No spools match your filter" + Clear filter chip button |
|
||||
| **Error** | Error illustration + "Couldn't load inventory" + Retry button |
|
||||
| **Low Stock item** | Yellow tonal badge "Low" + progress bar < 25% yellow |
|
||||
| **Critical item** | Red tonal badge "Critical" + progress bar < 10% red + pulsing dot |
|
||||
| **Depleted item** | Red tonal badge "Depleted" + progress bar 0% + strikethrough title |
|
||||
|
||||
---
|
||||
|
||||
## 4. UX Rationale
|
||||
|
||||
1. **Progress bars beat numbers alone.** A visual progress indicator communicates remaining spool life faster than parsing "210g / 1000g" — critical in a busy workshop where decisions are made in seconds.
|
||||
|
||||
2. **Low stock first (default sort).** Operators need to see what's running out before what's plentiful. This prevents the "surprise empty spool" problem mid-print.
|
||||
|
||||
3. **Color swatches as leading elements.** Filament identification is primarily visual — operators scan for color before reading text. The swatch leverages pre-attentive processing.
|
||||
|
||||
4. **Single-select filters over multi-select.** Workshop operators don't construct complex queries. They want one quick filter tap. Multi-select adds cognitive load for minimal practical benefit.
|
||||
|
||||
5. **Smart Intake FAB, not a menu item.** Intake is the most frequent action (new spools arrive regularly). It deserves the highest-affordance control on screen — a FAB is unmissable.
|
||||
|
||||
6. **Search collapses by default.** On kiosk, screen space is precious. The search icon expands the bar only when needed, preserving vertical list space.
|
||||
|
||||
7. **Empty states with action.** An empty inventory list should immediately offer the path forward — "Add your first spool" links directly to Smart Intake.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
### Typography (MD3 Type Scale)
|
||||
|
||||
| Role | Token | Size | Weight | Line Height |
|
||||
|------|-------|------|--------|-------------|
|
||||
| App Bar Title | `titleLarge` | 22sp | 400 | 28sp |
|
||||
| List Item Line 1 | `titleMedium` | 16sp | 500 | 24sp |
|
||||
| List Item Line 2 | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| List Item Line 3 | `labelSmall` | 11sp | 500 | 16sp |
|
||||
| Filter Chip Text | `labelLarge` | 14sp | 500 | 20sp |
|
||||
| FAB Label | `labelLarge` | 14sp | 500 | 20sp |
|
||||
| Empty State Title | `headlineSmall` | 24sp | 400 | 32sp |
|
||||
| Empty State Body | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
|
||||
### Spacing (MD3 8dp grid)
|
||||
|
||||
| Element | Spacing |
|
||||
|---------|---------|
|
||||
| App Bar internal padding | 16dp horizontal, 12dp vertical |
|
||||
| Filter chip row padding | 16dp horizontal, 8dp vertical |
|
||||
| Chip gap | 8dp |
|
||||
| List item padding | 16dp horizontal, 12dp vertical (top/bottom of 3-line) |
|
||||
| List item internal gap | 16dp (leading to content), 16dp (content to trailing) |
|
||||
| FAB margin | 16dp from edges |
|
||||
| Content area padding | 0dp (list is edge-to-edge with dividers) |
|
||||
|
||||
### Color (MD3 Dark Theme — "Industrial Maker")
|
||||
|
||||
Based on a **blue-grey** primary seed for the industrial feel, with **teal** as secondary (maker accent).
|
||||
|
||||
| Role | Token | Value (Dark) | Usage |
|
||||
|------|-------|-------------|-------|
|
||||
| Background | `surface` | `#1C1B1F` | Screen background |
|
||||
| On Background | `onSurface` | `#E6E1E5` | Primary text |
|
||||
| Surface Variant | `surfaceContainer` | `#211F26` | App bar, list items |
|
||||
| On Surface Variant | `onSurfaceVariant` | `#CAC4D0` | Secondary text, chip outlines |
|
||||
| Primary | `primary` | `#A8CEDA` | FAB container, active states |
|
||||
| On Primary | `onPrimary` | `#00303E` | FAB label text |
|
||||
| Primary Container | `primaryContainer` | `#004D63` | FAB container (tonal), active chip fill |
|
||||
| On Primary Container | `onPrimaryContainer` | `#A8CEDA` | Active chip text |
|
||||
| Secondary | `secondary` | `#B1CCC7` | Navigation rail active icon |
|
||||
| Tertiary | `tertiary` | `#EFB8C8` | Accent (not used here) |
|
||||
| Error | `error` | `#F2B8B5` | Depleted badge, critical states |
|
||||
| Error Container | `errorContainer` | `#8C1D18` | Critical badge background |
|
||||
| On Error Container | `onErrorContainer` | `#F2B8B5` | Critical badge text |
|
||||
| **Custom: Warning** | — | `#FFD580` | Low stock indicator |
|
||||
| **Custom: Warning Container** | — | `#5D4200` | Low stock badge background |
|
||||
| **Custom: Success** | — | `#8BD0A0` | Available badge text |
|
||||
| **Custom: Success Container** | — | `#00522E` | Available badge background |
|
||||
| Outline | `outline` | `#938F99` | Dividers, chip outlines |
|
||||
| Outline Variant | `outlineVariant` | `#49454F` | Subtle borders |
|
||||
|
||||
### Color Swatches
|
||||
Spool color swatches use the actual filament color as a circular filled element (40dp diameter) with a 2dp `outlineVariant` border for visibility on dark backgrounds. For transparent/clear filaments, use a diagonal hatch pattern fill.
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsiveness
|
||||
|
||||
### Kiosk (800×480, Raspberry Pi 5 Touchscreen)
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ Filament Inventory [🔍] [⋮] │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ [All] [Available] [In Use] [Low] [Depleted] │
|
||||
│ 📦 │──────────────────────────────────────────────│
|
||||
│ 🖨️ │ ● PLA Basic - Matte Black │
|
||||
│ 📋 │ Bambu Lab • Slot A3 • 842g │
|
||||
│ ⚙️ │ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ ● PETG Basic - Transparent │
|
||||
│ │ Polymaker • Shelf B2 • 210g │
|
||||
│ │ ▓▓▓░░░░░░░░░ 21% [Critical] │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ ● ABS Basic - White │
|
||||
│ │ eSUN • Slot B1 • 950g │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓ 95% [Available] │
|
||||
│ │ [+ Smart │
|
||||
│ │ Intake] ↗ │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Navigation Rail:** 80dp wide, pinned left
|
||||
- **List area:** 720dp × 420dp usable
|
||||
- **Visible items:** ~4-5 spools without scrolling
|
||||
- **Touch targets:** All interactive elements ≥ 48dp (exceeds 44dp minimum for workshop glove use)
|
||||
- **FAB:** Bottom-right, 16dp margin from rail and screen edge
|
||||
|
||||
### Mobile PWA (375×812, e.g., iPhone 14)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Filament Inventory [🔍] [⋮] │
|
||||
│──────────────────────────────────│
|
||||
│ [All] [Avail] [In Use] [Low] [×]│
|
||||
│──────────────────────────────────│
|
||||
│ ● PLA Basic - Matte Black │
|
||||
│ Bambu Lab • Slot A3 • 842g │
|
||||
│ ▓▓▓▓▓▓▓▓▓▓░░ 67% [Low] │
|
||||
│──────────────────────────────────│
|
||||
│ ● PETG Basic - Transparent │
|
||||
│ Polymaker • Shelf B2 • 210g │
|
||||
│ ▓▓▓░░░░░░░░░ 21% [Critical] │
|
||||
│──────────────────────────────────│
|
||||
│ ● ABS Basic - White │
|
||||
│ eSUN • Slot B1 • 950g │
|
||||
│ ▓▓▓▓▓▓▓▓▓▓▓▓ 95% [Avail] │
|
||||
│──────────────────────────────────│
|
||||
│ │
|
||||
│ [+ Smart Intake] ↗ │
|
||||
│──────────────────────────────────│
|
||||
│ 📦 🖨️ 📋 ⚙️ │
|
||||
│ Inventory Printers Jobs Settings │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Bottom Navigation:** 80dp, pinned bottom
|
||||
- **List area:** 375dp × ~600dp usable
|
||||
- **Visible items:** ~5-6 spools without scrolling
|
||||
- **FAB:** Floating above bottom nav, right-aligned
|
||||
- **Filter chips:** Horizontally scrollable when more than screen width
|
||||
|
||||
### Key Adaptations
|
||||
| Property | Kiosk (800×480) | Mobile (375×812) |
|
||||
|----------|-----------------|-------------------|
|
||||
| Navigation | Rail (left) | Bottom bar |
|
||||
| Search | Collapsed icon by default | Same, but swipe-down gesture also expands |
|
||||
| List item density | Comfortable (3-line, 72dp) | Same density |
|
||||
| FAB position | Bottom-right, above list | Bottom-right, above nav bar |
|
||||
| Filter chips | All visible at once | Horizontally scrollable |
|
||||
| Sort access | Overflow menu | Overflow menu |
|
||||
|
||||
---
|
||||
|
||||
## 7. Developer Handoff Notes
|
||||
|
||||
### Angular Material Components
|
||||
|
||||
| UI Element | Angular Material Component | Notes |
|
||||
|-----------|--------------------------|-------|
|
||||
| Top App Bar | `<mat-toolbar>` | Use `@angular/material/toolbar`. Medium emphasis variant. |
|
||||
| Search | Custom wrapper + `<input matInput>` | No native mat-search-bar in Angular Material; implement custom expandable search with `@ViewChild` animation. |
|
||||
| Filter Chips | `<mat-chip-listbox>` | Use `mat-chip-option` with `selected` binding. Single-select: deselect others on select. |
|
||||
| List Items | `<mat-list>` + `<mat-list-item>` | 3-line variant with `matListItemTitle`, `matListItemLine`, `matListItemMeta`. |
|
||||
| Progress Bar | `<mat-progress-bar>` | Use `mode="determinate"` with `[value]` binding. Custom color classes for warning/error thresholds. |
|
||||
| Badge | `<mat-badge>` | Overlay on list items. Use `matBadgeColor` for status colors. |
|
||||
| Extended FAB | `<button mat-fab extended>` | Use `@angular/material/button`. `extended` attribute for label. |
|
||||
| Nav Rail | `<mat-sidenav>` styled as rail | No native nav-rail in Angular Material yet; implement as styled sidenav with icon buttons. |
|
||||
| Bottom Nav | `<mat-bottom-nav>` or custom | Use `<nav mat-tab-nav-bar>` positioned at bottom as a workaround. |
|
||||
| Empty State | Custom component | Illustration (SVG) + `<button mat-button>`. |
|
||||
| Loading | `<mat-spinner>` | Use `mode="indeterminate"` centered in list area. |
|
||||
|
||||
### Interaction Notes
|
||||
|
||||
1. **Search expansion:** Animate height from 0→56dp with `@angular/animations` (`expandCollapse` trigger, 200ms ease-out).
|
||||
2. **Filter chip selection:** Single-select logic in component — on chip click, set `selectedChip = chip.value`, deselect all others.
|
||||
3. **List item tap → navigation:** Use `routerLink="/spools/:id"` on each `mat-list-item`.
|
||||
4. **FAB → Smart Intake:** Navigates to `/intake/scan`.
|
||||
5. **Progress bar color:** Dynamically set based on percentage:
|
||||
- ≥ 25%: `primary` (teal-blue)
|
||||
- 10-24%: Custom `warn-yellow` class
|
||||
- < 10%: `warn` (red)
|
||||
6. **Skeleton loading:** Use `@angular/material` skeleton pattern or custom shimmer CSS animation on list items during initial load.
|
||||
7. **Pull-to-refresh (mobile):** Implement with Angular CDK `@cdk/drag-drop` or a custom gesture handler to refresh inventory data.
|
||||
8. **Infinite scroll / pagination:** Load 20 spools initially, load more on scroll-to-bottom.
|
||||
|
||||
### Accessibility
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|---------------|
|
||||
| Screen reader | Each list item: `aria-label="PLA Basic Matte Black, Bambu Lab, Slot A3, 842 grams remaining, 67 percent, Low stock"` |
|
||||
| Color swatch | `aria-label="Color: Matte Black"` + `role="img"` |
|
||||
| Progress bar | `aria-valuenow`, `aria-valuemin="0"`, `aria-valuemax="100"`, `aria-label="Remaining filament: 67 percent"` |
|
||||
| Filter chips | `role="listbox"`, each chip `role="option"` + `aria-selected` |
|
||||
| Search | `role="search"`, `aria-label="Search filament inventory"` |
|
||||
| FAB | `aria-label="Smart Intake: scan new spool"` |
|
||||
| Keyboard nav | Tab through list items, Enter to select. Escape to dismiss search. |
|
||||
| Focus management | On search expand, auto-focus input. On search collapse, return focus to search icon. |
|
||||
| Motion reduction | Respect `prefers-reduced-motion` — disable shimmer, use opacity fade for loading. |
|
||||
| Contrast | All text meets WCAG AA (4.5:1) against dark surface. Verified: `onSurface` (#E6E1E5) on `surface` (#1C1B1F) = 11.2:1 ✓ |
|
||||
|
||||
### SignalR Integration
|
||||
|
||||
- Subscribe to `SpoolUpdated` hub event on screen init.
|
||||
- On event: update the corresponding list item in-place (no full refresh).
|
||||
- On `SpoolAdded`: insert at sorted position with highlight animation (300ms `primaryContainer` background flash).
|
||||
- On `SpoolDepleted`: move to Depleted filter group, show toast notification.
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- Use `TrackByFunction` on `*ngFor` to prevent full list re-render on updates.
|
||||
- Virtual scrolling via `<cdk-virtual-scroll-viewport>` if inventory exceeds 100 spools.
|
||||
- Debounce search input at 300ms.
|
||||
424
design/02-spool-detail-view.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Spool Detail View — Screen Specification
|
||||
|
||||
> **Screen ID:** FIL-002
|
||||
> **Source of Truth:** [Material Design 3](https://m3.material.io/)
|
||||
> **Tone:** Modern Industrial/Maker
|
||||
> **Theme:** Dark Mode, High-Contrast
|
||||
> **Last Updated:** 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Present comprehensive, actionable information about a single filament spool. Users (shop operators) must be able to:
|
||||
|
||||
- View all spool metadata (material, brand, color, weight, location, dates).
|
||||
- See real-time remaining weight with visual progress indicator.
|
||||
- Manage QR code (view, reprint label).
|
||||
- View consumption history / usage timeline.
|
||||
- Perform quick actions: edit details, move location, mark depleted, initiate print.
|
||||
- Navigate back to inventory or forward to related entities (printer, location).
|
||||
|
||||
This is the **drill-down screen** from the Filament Inventory List.
|
||||
|
||||
---
|
||||
|
||||
## 2. Screen Inventory
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Top App Bar | `md-top-app-bar` (small) | Back nav + title + actions |
|
||||
| Hero Section | Custom card | Color swatch + material name + status badge |
|
||||
| Progress Ring | `md-circular-progress` (determinate) | Circular remaining-weight indicator |
|
||||
| Metrics Grid | CSS Grid in `md-card` | Key metrics in 2-col grid |
|
||||
| QR Code Card | `md-card` (elevated) | QR display + reprint button |
|
||||
| Location Section | `md-card` (outlined) | Current location + move action |
|
||||
| Usage Timeline | `md-list` (2-line) | Recent consumption events |
|
||||
| Action Row | `md-button` row | Primary + secondary actions |
|
||||
| Snackbar | `md-snackbar` | Confirmation for destructive actions |
|
||||
| Dialog | `md-dialog` | Move location, confirm depletion |
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Specification
|
||||
|
||||
### Title
|
||||
**Spool detail name** — displayed in the Top App Bar headline slot (material short name, e.g., "PLA Basic — Matte Black").
|
||||
|
||||
### Sections
|
||||
|
||||
#### A. Top App Bar (64dp)
|
||||
- **Leading:** Back arrow → returns to Inventory List
|
||||
- **Title:** Material name (scrolls into collapsed bar on scroll)
|
||||
- **Trailing actions:** Edit icon (pencil), More (overflow: Delete spool, Transfer data)
|
||||
|
||||
#### B. Hero Section (200dp mobile / 180dp kiosk)
|
||||
A prominent visual header that establishes identity at a glance:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────┐ │
|
||||
│ │ COLOR│ PLA Basic — Matte Black │
|
||||
│ │SWATCH│ Bambu Lab │
|
||||
│ │ 80dp │ │
|
||||
│ └──────┘ [Available] │
|
||||
│ │
|
||||
│ ◉ 842g / 1000g │
|
||||
│ 67% remaining │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left:** Large circular color swatch (80dp) with `outlineVariant` border
|
||||
- **Right of swatch:**
|
||||
- Line 1: Full material name (`headlineMedium`) — MaterialBase + Finish + Modifier
|
||||
- Line 2: Brand name (`bodyLarge`, `onSurfaceVariant`)
|
||||
- **Below swatch row:** Status badge (tonal chip, same as inventory)
|
||||
- **Bottom:** Large circular progress indicator (96dp ring)
|
||||
- Track: `surfaceContainerHighest`
|
||||
- Indicator: Dynamic color (≥25% primary, 10-24% warning, <10% error)
|
||||
- Center text: Remaining weight + percentage (`headlineSmall`)
|
||||
|
||||
#### C. Metrics Grid (2-column)
|
||||
|
||||
```
|
||||
┌────────────────────┬────────────────────┐
|
||||
│ 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 │
|
||||
└────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
- Each cell: Label (`labelMedium`, `onSurfaceVariant`) + Value (`titleMedium`, `onSurface`)
|
||||
- Grid gap: 1dp `outlineVariant` lines between cells
|
||||
- Container: `surfaceContainer` background, `rounded-xl` (16dp radius)
|
||||
- Cell padding: 16dp
|
||||
|
||||
#### D. Location Card (Outlined, 72dp)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 📍 AMS Unit 2, Slot A3 [Move]│
|
||||
│ Bambu Lab P1S — Left spool holder │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Leading:** Location pin icon
|
||||
- **Line 1:** Location name (`titleSmall`, `onSurface`)
|
||||
- **Line 2:** Printer/host description (`bodySmall`, `onSurfaceVariant`)
|
||||
- **Trailing:** "Move" text button (`primary`) → opens Move Location dialog
|
||||
|
||||
#### E. QR Code Card (Elevated, 200dp × 200dp QR)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ┌──────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ QR CODE │ │
|
||||
│ │ (160dp) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┘ │
|
||||
│ EXT-2026-PLA-0042 │
|
||||
│ [Reprint] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- QR code: 160dp × 160dp, white on dark surface
|
||||
- Below QR: Internal tracking ID in monospace (`labelMedium`, `onSurfaceVariant`)
|
||||
- Trailing: "Reprint" text button → sends to Bluetooth thermal printer
|
||||
- Card: `surfaceContainerHigh` background, elevation 1
|
||||
|
||||
#### F. Usage Timeline (Expandable section)
|
||||
|
||||
```
|
||||
│ Recent Usage │
|
||||
│──────────────────────────────────────────────────│
|
||||
│ 📦 Print #1847 — Benchy v3 -32g Apr 18│
|
||||
│ 📦 Print #1842 — Housing Cap -18g Apr 17│
|
||||
│ 📦 Print #1839 — Gear Test -8g Apr 16│
|
||||
│ 📦 Added to inventory +1000g Mar 12│
|
||||
│ │
|
||||
│ [View Full History →] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Each entry: 2-line list item
|
||||
- Line 1: Print name or event (`bodyMedium`, `onSurface`)
|
||||
- Line 2: Weight change + date (`bodySmall`, `onSurfaceVariant`)
|
||||
- Negative weight: `error` color
|
||||
- Positive weight: custom `success` color
|
||||
- Trailing: "View Full History" link → separate screen (future)
|
||||
|
||||
#### G. Action Row (Sticky bottom, 64dp)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ [Mark Depleted] [Move Location] [Print] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Mark Depleted:** Outlined button, `error` color → confirmation dialog
|
||||
- **Move Location:** Tonal button, `secondary` → Move Location dialog
|
||||
- **Print:** Filled button, `primary` → Navigate to start print job (pre-select this spool)
|
||||
|
||||
On kiosk, these are larger touch targets (56dp height buttons).
|
||||
|
||||
### Primary CTA
|
||||
**Print** (filled button) — the most likely next action after viewing spool details.
|
||||
|
||||
### Secondary Actions
|
||||
- Edit (Top App Bar icon) → Edit spool details form
|
||||
- Reprint label → Send QR to thermal printer
|
||||
- Move Location → Dialog with location picker
|
||||
- Mark Depleted → Confirmation dialog → status update
|
||||
- Delete spool → Overflow menu → confirmation dialog
|
||||
|
||||
### Key Components
|
||||
- Large circular color swatch
|
||||
- Determinate circular progress indicator
|
||||
- 2-column metrics grid
|
||||
- QR code display card
|
||||
- Location card with move action
|
||||
- Usage timeline list
|
||||
- Sticky bottom action row
|
||||
|
||||
### States
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| **Loading** | `md-circular-progress` indeterminate centered, skeleton sections |
|
||||
| **Available (normal)** | Primary color progress ring, "Available" badge |
|
||||
| **In Use** | Primary color progress ring, "In Use" badge, pulsing dot |
|
||||
| **Low Stock** | Warning (yellow) progress ring, "Low Stock" badge |
|
||||
| **Critical** | Error (red) progress ring, "Critical" badge, pulsing dot |
|
||||
| **Depleted** | Error (red) progress ring at 0%, "Depleted" badge, strikethrough weight, "Mark Depleted" button becomes "Reactivate" |
|
||||
| **No Location** | Location card shows "Unassigned" + "Assign" button |
|
||||
| **QR Missing** | QR card shows "No QR generated" + "Generate" button |
|
||||
| **Move Dialog** | Bottom sheet (mobile) / Center dialog (kiosk) with location tree/grid selector |
|
||||
|
||||
---
|
||||
|
||||
## 4. UX Rationale
|
||||
|
||||
1. **Hero section for instant recognition.** When an operator scans a spool or taps into it, they need immediate visual confirmation: "Is this the spool I think it is?" The large color swatch + material name + brand answers this in <1 second.
|
||||
|
||||
2. **Circular progress over linear.** For a single spool, a circular progress ring is more impactful and glanceable than a linear bar. It works as a "fuel gauge" metaphor that operators intuitively understand.
|
||||
|
||||
3. **Metrics grid, not a list.** A 2-column grid presents data-dense information efficiently. Each metric is a quick scan without vertical scrolling. This is the "dashboard gauge" pattern adapted for a single entity.
|
||||
|
||||
4. **QR card always visible.** The QR code is the physical-digital bridge. Operators need to see it to verify labels match, and the reprint action is frequent (labels get damaged/lost on spools).
|
||||
|
||||
5. **Timeline for consumption context.** "How fast am I going through this spool?" The recent usage list answers this without requiring a separate analytics screen. It's contextual, not analytical.
|
||||
|
||||
6. **Sticky action row.** The three most common actions (deplete, move, print) should never be hidden by scroll. Pinning them ensures they're always one tap away.
|
||||
|
||||
7. **"Mark Depleted" is destructive but necessary.** It gets an outlined (not filled) style and requires a confirmation dialog to prevent accidental taps — but it's prominent because operators genuinely need this action often.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
### Typography (MD3 Type Scale)
|
||||
|
||||
| Role | Token | Size | Weight | Line Height |
|
||||
|------|-------|------|--------|-------------|
|
||||
| App Bar Title | `titleLarge` | 22sp | 400 | 28sp |
|
||||
| Hero Material Name | `headlineMedium` | 28sp | 400 | 36sp |
|
||||
| Hero Brand | `bodyLarge` | 16sp | 400 | 24sp |
|
||||
| Progress Center Value | `headlineSmall` | 24sp | 400 | 32sp |
|
||||
| Progress Center Sub | `labelMedium` | 12sp | 500 | 16sp |
|
||||
| Metrics Label | `labelMedium` | 12sp | 500 | 16sp |
|
||||
| Metrics Value | `titleMedium` | 16sp | 500 | 24sp |
|
||||
| Card Title | `titleSmall` | 14sp | 500 | 20sp |
|
||||
| Card Body | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| Timeline Entry | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| Timeline Detail | `bodySmall` | 12sp | 400 | 16sp |
|
||||
| Action Button | `labelLarge` | 14sp | 500 | 20sp |
|
||||
| Tracking ID | `labelMedium` (monospace) | 12sp | 500 | 16sp |
|
||||
|
||||
### Spacing (MD3 8dp grid)
|
||||
|
||||
| Element | Spacing |
|
||||
|---------|---------|
|
||||
| Hero section padding | 24dp horizontal, 20dp vertical |
|
||||
| Swatch to text gap | 16dp |
|
||||
| Hero to metrics gap | 16dp |
|
||||
| Metrics grid cell padding | 16dp |
|
||||
| Metrics grid gap (divider) | 1dp |
|
||||
| Section gap (between cards) | 12dp |
|
||||
| Card internal padding | 16dp |
|
||||
| QR code padding | 24dp (centered) |
|
||||
| Timeline item padding | 16dp horizontal, 12dp vertical |
|
||||
| Action row padding | 16dp horizontal, 12dp vertical |
|
||||
| Action button gap | 12dp |
|
||||
|
||||
### Color (MD3 Dark Theme — "Industrial Maker")
|
||||
|
||||
Same base palette as FIL-001. Screen-specific additions:
|
||||
|
||||
| Role | Token | Value (Dark) | Usage |
|
||||
|------|-------|-------------|-------|
|
||||
| Hero Background | `surfaceContainerLow` | `#1D1B20` | Hero section fill |
|
||||
| Metrics Background | `surfaceContainer` | `#211F26` | Grid card |
|
||||
| QR Card Background | `surfaceContainerHigh` | `#2B2930` | Elevated card |
|
||||
| Location Card Background | `surfaceContainer` | `#211F26` | Outlined card |
|
||||
| Progress Track | `surfaceContainerHighest` | `#36343B` | Ring background track |
|
||||
| Depleted Strikethrough | `error` | `#F2B8B5` | Weight text decoration |
|
||||
|
||||
### Circular Progress Colors (Dynamic)
|
||||
|
||||
| Threshold | Color Token | Visual |
|
||||
|-----------|------------|--------|
|
||||
| ≥ 25% | `primary` | `#A8CEDA` (teal-blue) |
|
||||
| 10–24% | Custom `warning` | `#FFD580` (amber) |
|
||||
| < 10% | `error` | `#F2B8B5` (red) |
|
||||
| 0% (Depleted) | `error` | `#F2B8B5` + pulsing animation |
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsiveness
|
||||
|
||||
### Kiosk (800×480)
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ ← PLA Basic — Matte Black [✏] [⋮] │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ ┌──────┐ │
|
||||
│ │ │SWATCH│ PLA Basic — Matte Black [Avail] │
|
||||
│ │ │ 80dp │ Bambu Lab │
|
||||
│ │ └──────┘ │
|
||||
│ │ ◉ 842g / 1000g (67%) │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ Total: 1000g │ Remaining: 842g │ Diam: 1.75│
|
||||
│ │ Dens: 1.24 │ Added: Mar 12 │ Last: Apr18│
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ 📍 AMS Unit 2, Slot A3 [Move] │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ [Depleted] [Move Location] [Print] │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Horizontal layout advantage:** Metrics grid uses 3 columns on kiosk to fill width.
|
||||
- **QR code and timeline:** Accessible via scroll or tab section below the fold.
|
||||
- **Scrollable content area:** Hero + Metrics + Location fit above fold; QR + Timeline scroll into view.
|
||||
- **Action row:** Sticky at bottom of scroll area, above Navigation Rail.
|
||||
|
||||
### Mobile PWA (375×812)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ← PLA Basic — Matte Black [✏] │
|
||||
│──────────────────────────────────│
|
||||
│ ┌──────┐ │
|
||||
│ │SWATCH│ PLA Basic — Matte │
|
||||
│ │ 80dp │ Black [Avail] │
|
||||
│ └──────┘ Bambu Lab │
|
||||
│ │
|
||||
│ ◉ 842g / 1000g (67%) │
|
||||
│──────────────────────────────────│
|
||||
│ 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] │
|
||||
│──────────────────────────────────│
|
||||
│ 📦 🖨️ 📋 ⚙️ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Vertical scroll layout:** All sections stack vertically.
|
||||
- **Metrics grid:** 2 columns on mobile (3 on kiosk).
|
||||
- **Action row:** Sticky at bottom, above bottom navigation.
|
||||
- **QR code:** Full-width card, scroll into view.
|
||||
|
||||
### Key Adaptations
|
||||
|
||||
| Property | Kiosk (800×480) | Mobile (375×812) |
|
||||
|----------|-----------------|-------------------|
|
||||
| Metrics columns | 3 (compact) | 2 (standard) |
|
||||
| Hero section height | 180dp (compact) | 200dp (standard) |
|
||||
| QR card position | Below fold, scroll | Below fold, scroll |
|
||||
| Action row style | Larger buttons (56dp h) | Standard buttons (40dp h) |
|
||||
| Timeline items visible | 2-3 | 3-4 |
|
||||
| Move dialog | Center `mat-dialog` | Bottom sheet (`mat-bottom-sheet`) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Developer Handoff Notes
|
||||
|
||||
### Angular Material Components
|
||||
|
||||
| UI Element | Angular Material Component | Notes |
|
||||
|-----------|--------------------------|-------|
|
||||
| Top App Bar | `<mat-toolbar>` | Collapsible title on scroll using `@angular/cdk/scrolling` |
|
||||
| Color Swatch | Custom `div` | Circular, 80dp, `[style.backgroundColor]` binding |
|
||||
| Circular Progress | `<mat-progress-spinner>` | `mode="determinate"`, `[value]` binding, custom color classes |
|
||||
| Metrics Grid | CSS Grid inside `<mat-card>` | 2-col (mobile) / 3-col (kiosk) via CSS breakpoints |
|
||||
| QR Code Card | `<mat-card>` elevated | Use `appearance="elevated"`. QR rendered via `qrcode` npm package or canvas |
|
||||
| Location Card | `<mat-card>` outlined | Use `appearance="outlined"`. |
|
||||
| Timeline | `<mat-list>` + `<mat-list-item>` | 2-line items with `matListItemIcon` for type icon |
|
||||
| Action Buttons | `<button mat-button>` variants | Depleted: `mat-button` with `color="warn"`. Move: `mat-flat-button` with `color="accent"`. Print: `mat-raised-button` with `color="primary"` |
|
||||
| Move Dialog | `<mat-dialog>` / `<mat-bottom-sheet>` | Responsive: dialog on kiosk, bottom sheet on mobile |
|
||||
| Confirmation Dialog | `<mat-dialog>` | With `mat-dialog-actions`: Cancel + Confirm |
|
||||
| Snackbar | `<mat-snack-bar>` | For success/error feedback on actions |
|
||||
|
||||
### Interaction Notes
|
||||
|
||||
1. **Collapsible title:** As user scrolls past hero, App Bar title animates from hidden to visible (standard M3 scroll behavior). Use `@angular/material` scroll dispatching.
|
||||
2. **Progress ring animation:** On load, animate from 0 to actual value over 600ms with `ease-out`.
|
||||
3. **QR code generation:** If `spool.qrCode` is null, show "Generate" button. On tap, call API to generate QR, then display.
|
||||
4. **Reprint label:** Calls Bluetooth thermal printer service. Show snackbar: "Label sent to printer ✓" or "Printer not connected ✗".
|
||||
5. **Move location dialog:** Show location tree (AMS units → slots, shelves). Selected location highlighted. Confirm → API call → snackbar confirmation.
|
||||
6. **Mark Depleted:** Dialog: "Mark PLA Basic — Matte Black as depleted? This will update the spool's status and remove it from available inventory." Buttons: Cancel (text) / Mark Depleted (error filled).
|
||||
7. **Delete spool:** Dialog: "Permanently delete PLA Basic — Matte Black? This cannot be undone." Buttons: Cancel (text) / Delete (error filled).
|
||||
8. **Real-time updates:** Subscribe to `SpoolUpdated` SignalR event. If this spool's weight changes, animate the progress ring and update metrics in-place.
|
||||
|
||||
### Accessibility
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|---------------|
|
||||
| Screen reader (hero) | `aria-label="PLA Basic Matte Black by Bambu Lab, Available, 842 grams remaining of 1000 grams, 67 percent"` |
|
||||
| Circular progress | `role="progressbar"`, `aria-valuenow="67"`, `aria-valuemin="0"`, `aria-valuemax="100"`, `aria-label="Remaining filament"` |
|
||||
| Metrics grid | Semantic `<dl>` with `<dt>` (label) + `<dd>` (value), each with `aria-label` |
|
||||
| QR code | `alt="QR code for spool EXT-2026-PLA-0042"`, `role="img"` |
|
||||
| Color swatch | `aria-label="Filament color: Matte Black"`, `role="img"` |
|
||||
| Action buttons | Clear `aria-label`: "Mark as depleted", "Move to new location", "Start print with this spool" |
|
||||
| Confirmation dialogs | `aria-labelledby` for title, `aria-describedby` for description. Focus trap. Escape to close. |
|
||||
| Keyboard nav | Tab order: Back → Edit → Overflow → Content sections → Action row. Enter to activate. |
|
||||
| Focus on open | When navigating from inventory, focus lands on back button, then natural tab flow |
|
||||
|
||||
### SignalR Integration
|
||||
|
||||
- Subscribe to `SpoolUpdated` hub event, filter by current spool ID.
|
||||
- On weight change: animate progress ring delta, update metrics, prepend usage timeline entry.
|
||||
- On status change: update badge, adjust progress ring color, update action row buttons.
|
||||
- On location change: update location card.
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- Use `OnPush` change detection strategy for this component.
|
||||
- QR code should be generated once and cached; don't regenerate on every change detection cycle.
|
||||
- Usage timeline: load last 5 entries on init, lazy-load full history on "View Full History" tap.
|
||||
738
design/03-smart-intake-workflow.md
Normal file
@@ -0,0 +1,738 @@
|
||||
# Smart Intake Workflow — Screen Specification
|
||||
|
||||
> **Screen ID:** FIL-003
|
||||
> **Source of Truth:** [Material Design 3](https://m3.material.io/)
|
||||
> **Tone:** Modern Industrial/Maker
|
||||
> **Theme:** Dark Mode, High-Contrast
|
||||
> **Last Updated:** 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Design the primary filament intake experience — the "Smart Intake" workflow — optimized for both the kiosk (USB barcode scanner + touchscreen) and the mobile PWA (device camera + touch). This is the **most critical user flow** in Extrudex: every new spool enters the system through this workflow.
|
||||
|
||||
The workflow follows a strict **3-state linear progression**:
|
||||
|
||||
```
|
||||
SCAN ──→ IDENTIFY ──→ UPDATE ──→ ✓ Complete
|
||||
```
|
||||
|
||||
Users must be able to:
|
||||
- **Scan:** Capture a barcode/QR code via camera (mobile) or USB scanner (kiosk).
|
||||
- **Identify:** Confirm or correct the scanned spool's identity (material, brand, color).
|
||||
- **Update:** Set initial weight and status, assign a location, and complete intake.
|
||||
|
||||
The workflow must feel **fast, confident, and forgiving** — operators process many spools in a session and can't afford friction or ambiguity at any step.
|
||||
|
||||
---
|
||||
|
||||
## 2. Screen Inventory
|
||||
|
||||
### Shared Across All States
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Top App Bar | `md-top-app-bar` (small) | Title + close/dismiss |
|
||||
| Step Indicator | Custom linear stepper | 3 dots/steps with active state |
|
||||
| Bottom Sheet | `md-bottom-sheet` | Contextual info/errors on mobile |
|
||||
|
||||
### Scan State (State 1 of 3)
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Camera Viewport | Custom + `video` element | Full-bleed camera with scanning overlay |
|
||||
| Viewport Frame | Custom overlay | Animated scanning rectangle |
|
||||
| Scan Result Chip | `md-chip` (assist) | Shows last scanned code |
|
||||
| Manual Entry Link | `md-text-button` | Fallback for unreadable codes |
|
||||
| USB Scanner Badge | `md-chip` (status) | Kiosk only — "Scanner Connected ✓" |
|
||||
|
||||
### Identification State (State 2 of 3)
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Spool Identity Card | `md-card` (filled) | Material, brand, color display |
|
||||
| Confidence Indicator | Custom bar | Match confidence (High/Medium/Low/Unknown) |
|
||||
| Material Selector | `md-dropdown-select` | If multiple matches or no match |
|
||||
| Brand Selector | `md-dropdown-select` | Brand selection |
|
||||
| Color Picker | Custom swatch grid | Filament color selection |
|
||||
| Finish + Modifier | `md-chip-set` (choice) | Material finish and modifier |
|
||||
| "Confirm" Button | `md-filled-button` | Primary CTA — proceed to Update |
|
||||
| "Rescan" Button | `md-outlined-button` | Go back to Scan state |
|
||||
|
||||
### Update State (State 3 of 3)
|
||||
|
||||
| Element | MD3 Component | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Weight Input | `md-filled-text-field` + stepper | Grams input with +/- buttons |
|
||||
| Location Selector | `md-dropdown-select` | Assign to AMS slot or shelf |
|
||||
| Status Toggle | `md-chip-set` (choice) | Available / In Use |
|
||||
| QR Preview | `md-card` | Generated QR code preview |
|
||||
| "Complete Intake" Button | `md-filled-button` | Primary CTA — finalize |
|
||||
| "Print Label" Toggle | `md-switch` | Auto-print QR label on completion |
|
||||
| Summary Card | `md-card` (outlined) | Final summary before submission |
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Specification
|
||||
|
||||
### Shared Layout
|
||||
|
||||
#### Top App Bar (56dp mobile / 64dp kiosk)
|
||||
- **Leading:** Close (✕) → returns to Inventory List with unsaved-work confirmation if mid-flow
|
||||
- **Title:** "Smart Intake" (all states)
|
||||
- **Trailing:** None (deliberate — no distractions during intake)
|
||||
|
||||
#### Step Indicator (40dp, below App Bar)
|
||||
|
||||
```
|
||||
● ───── ○ ───── ○
|
||||
Scan Identify Update
|
||||
```
|
||||
|
||||
- 3-step linear indicator
|
||||
- Active step: `primary` filled circle + `primary` connecting line
|
||||
- Completed step: `primary` filled circle + checkmark icon
|
||||
- Future step: `outline` circle + `outlineVariant` connecting line
|
||||
- Labels: `labelSmall` below each dot
|
||||
- Animation: Line fills with `primary` color as each step completes (300ms ease-out)
|
||||
|
||||
---
|
||||
|
||||
### STATE 1: SCAN
|
||||
|
||||
**Title:** "Scan Spool"
|
||||
|
||||
#### Layout — Mobile (Camera-based)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ✕ Smart Intake │
|
||||
│──────────────────────────────────│
|
||||
│ ● ──── ○ ──── ○ │
|
||||
│ Scan Identify Update │
|
||||
│──────────────────────────────────│
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────┐ │ │
|
||||
│ │ │ ╔════════════╗│ │ │
|
||||
│ │ │ ║ VIEWPORT ║│ │ │
|
||||
│ │ │ ║ FRAME ║│ │ │
|
||||
│ │ │ ║ (animated) ║│ │ │
|
||||
│ │ │ ╚════════════╝│ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▲ Align barcode │ │ │
|
||||
│ │ │ within frame │ │ │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ LIVE CAMERA FEED │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ [Last scan: 8901234567890] │
|
||||
│ │
|
||||
│ Can't scan? Enter manually │
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Camera viewport:** Full-bleed video feed with 16:9 or 4:3 aspect ratio
|
||||
- **Scanning frame:** Animated rectangle (200dp × 120dp) centered in viewport
|
||||
- Corner brackets: `primary` color, 24dp corner length, 3dp stroke
|
||||
- Scan line: Horizontal `primary` line that sweeps top-to-bottom repeatedly (2s cycle)
|
||||
- On successful scan: Frame flashes `success` color (green) + brief haptic (mobile)
|
||||
- **Instruction text:** Below frame, `bodyMedium`, `onSurfaceVariant`
|
||||
- **Scan result chip:** Appears below camera when a code is detected
|
||||
- Shows scanned code value in monospace
|
||||
- Auto-advances to Identification state after 1.5s (with option to cancel)
|
||||
- **Manual entry link:** Text button below camera area for fallback
|
||||
|
||||
#### Layout — Kiosk (USB Scanner-based)
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ ✕ Smart Intake │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ ● ──── ○ ──── ○ │
|
||||
│ │ Scan Identify Update │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ │ ┌──────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 📷 SCANNER READY │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ USB Scanner: ✓ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Scan a barcode or │ │
|
||||
│ │ │ QR code now... │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ ═══════════════ │ │
|
||||
│ │ │ (pulse animation) │ │
|
||||
│ │ │ │ │
|
||||
│ │ └──────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Or: [Enter Barcode Manually] │
|
||||
│ │ │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **No camera on kiosk** — USB HID barcode scanner is the primary input
|
||||
- **Scanner status card:** Shows connected/disconnected state
|
||||
- Connected: `success` icon + "Scanner Connected ✓"
|
||||
- Disconnected: `error` icon + "Scanner Not Found" + "Troubleshoot" button
|
||||
- **Pulse animation:** A horizontal line that pulses `primary` to indicate "waiting for scan"
|
||||
- **On scan:** The scanned code appears in a chip, auto-advances to Identification
|
||||
- **Manual entry:** Full keyboard (virtual) for typing barcode numbers
|
||||
|
||||
#### Scan State Elements
|
||||
|
||||
| Element | Description |
|
||||
|---------|-------------|
|
||||
| Primary CTA | None (scanning is automatic) — the camera/scanner IS the CTA |
|
||||
| Secondary Actions | Manual entry, close/dismiss |
|
||||
| Key Components | Camera viewport, scanning frame animation, scanner status (kiosk), scan result chip |
|
||||
| States | See below |
|
||||
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| **Initializing camera** | Shimmer loading in viewport + "Starting camera…" label |
|
||||
| **Ready (waiting for scan)** | Live feed + animated scan line + "Align barcode within frame" |
|
||||
| **Scanning (code detected)** | Frame flashes green + scan result chip appears + "Identified!" text |
|
||||
| **No match found** | Frame flashes yellow + chip + "Unknown barcode" → still advances to Identify (manual) |
|
||||
| **Camera denied** | Error card: "Camera access denied" + "Enter manually" button + permission settings link |
|
||||
| **Scanner disconnected (kiosk)** | Error card: "Scanner not detected" + "Reconnect scanner" + "Enter manually" |
|
||||
| **Multiple codes detected** | Shows list of detected codes as selectable chips → user picks correct one |
|
||||
|
||||
---
|
||||
|
||||
### STATE 2: IDENTIFY
|
||||
|
||||
**Title:** "Identify Spool"
|
||||
|
||||
#### Layout (Both platforms)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ✕ Smart Intake │
|
||||
│──────────────────────────────────│
|
||||
│ ✓ ────● ──── ○ │
|
||||
│ Scan Identify Update │
|
||||
│──────────────────────────────────│
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Scanned: 8901234567890 │ │
|
||||
│ │ Match: HIGH CONFIDENCE │ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓░░ 92% │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Material │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ PLA Basic ▾│ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Brand │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Bambu Lab ▾│ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Color │
|
||||
│ ● ● ● ● ● ● ● ● ● [Custom] │
|
||||
│ Bla Red Blu Grn Yel Org Pur Wh │
|
||||
│ │
|
||||
│ Finish Modifier │
|
||||
│ [Basic][Matte][Silk] [None] │
|
||||
│ │
|
||||
│ [Rescan] [Confirm →] │
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Confidence Indicator
|
||||
|
||||
A horizontal bar showing match confidence from the barcode lookup:
|
||||
|
||||
| Confidence | Visual | Color | Behavior |
|
||||
|-----------|--------|-------|----------|
|
||||
| **High** (≥80%) | Full green bar | `success` | Pre-fills all fields; user just confirms |
|
||||
| **Medium** (40-79%) | Yellow bar | `warning` | Pre-fills material/brand; color may need correction |
|
||||
| **Low** (<40%) | Orange bar | Custom orange | Pre-fills partial; more fields need manual input |
|
||||
| **Unknown** (0%) | Red bar | `error` | "Barcode not found" — all fields manual |
|
||||
|
||||
#### Material Selector
|
||||
- `mat-select` dropdown with searchable options
|
||||
- Options sourced from normalized taxonomy (MaterialBase list)
|
||||
- If confidence is High: pre-selected, field is in confirmed state (slight green tint)
|
||||
- If confidence is Low/Unknown: field is empty, highlighted for input
|
||||
|
||||
#### Brand Selector
|
||||
- `mat-select` dropdown with common brands
|
||||
- Searchable with "Other…" option that allows free-text entry
|
||||
|
||||
#### Color Picker
|
||||
- **Swatch grid:** 2 rows of circular color swatches (24dp each)
|
||||
- Common colors: Black, White, Red, Blue, Green, Yellow, Orange, Purple, Grey, Brown, Pink, Transparent
|
||||
- Tap swatch → selected (ring indicator)
|
||||
- "Custom" option → opens free-text color name input
|
||||
- Selected color stored as both name + hex value
|
||||
|
||||
#### Finish + Modifier Chips
|
||||
- **Finish (required):** `mat-chip-listbox` with single-select
|
||||
- Options: Basic, Matte, Silk, Sparkle, Metallic, Translucent
|
||||
- Default: "Basic"
|
||||
- **Modifier (optional):** `mat-chip-listbox` with single-select
|
||||
- Options: None, Carbon Fiber, Wood Fill, Glow-in-Dark, Marble, Gradient
|
||||
- Default: "None"
|
||||
|
||||
#### Identify State Elements
|
||||
|
||||
| Element | Description |
|
||||
|---------|-------------|
|
||||
| Primary CTA | **"Confirm"** filled button → advances to Update state |
|
||||
| Secondary Actions | **"Rescan"** outlined button → returns to Scan state |
|
||||
| Key Components | Confidence bar, Material dropdown, Brand dropdown, Color picker grid, Finish chips, Modifier chips |
|
||||
| Validation | Material and Brand are required. Color is required. Confirm button disabled until all required fields filled. |
|
||||
|
||||
---
|
||||
|
||||
### STATE 3: UPDATE
|
||||
|
||||
**Title:** "Update Spool"
|
||||
|
||||
#### Layout (Both platforms)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ✕ Smart Intake │
|
||||
│──────────────────────────────────│
|
||||
│ ✓ ────✓ ──── ● │
|
||||
│ Scan Identify Update │
|
||||
│──────────────────────────────────│
|
||||
│ │
|
||||
│ Initial Weight │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ [-] 1000 [+] grams │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ Quick: [250] [500] [750] [1000] │
|
||||
│ │
|
||||
│ Assign Location │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Select location... ▾│ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Status │
|
||||
│ [● Available] [○ In Use] │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Summary │ │
|
||||
│ │ PLA Basic - Matte Black │ │
|
||||
│ │ Bambu Lab • 1000g │ │
|
||||
│ │ AMS 2, Slot A3 • Avail │ │
|
||||
│ │ ID: EXT-2026-PLA-0043 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────┐ Print Label │ │
|
||||
│ │ │ QR │ [═══●═══] ON │ │
|
||||
│ │ │Preview│ │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ [◀ Back] [✓ Complete Intake] │
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Weight Input
|
||||
- **Stepper control:** Number input flanked by – and + buttons
|
||||
- Step size: 50g (adjustable via long-press to 10g/100g)
|
||||
- Minimum: 50g, Maximum: 5000g
|
||||
- Direct numeric keyboard input also available
|
||||
- **Quick-select chips:** Common spool sizes (250g, 500g, 750g, 1000g) — tap to set weight instantly
|
||||
- Active chip: `primaryContainer` fill
|
||||
- On kiosk: Larger chips (48dp height) for easy tapping
|
||||
- **Unit label:** "grams" suffix, non-editable, `onSurfaceVariant`
|
||||
|
||||
#### Location Selector
|
||||
- `mat-select` dropdown organized hierarchically:
|
||||
- **AMS Units** → Slot A1, A2, A3, A4
|
||||
- **External Holders** → Shelf B1, B2, B3
|
||||
- **Unassigned** (default)
|
||||
- Only shows available (unoccupied) locations
|
||||
- Selected location shows printer/host info as hint text
|
||||
|
||||
#### Status Toggle
|
||||
- `mat-chip-listbox` single-select:
|
||||
- **Available** (default): Green tonal chip + "Available" label
|
||||
- **In Use**: Blue tonal chip + "In Use" label
|
||||
- Most new spools are "Available" on intake — this is the safe default
|
||||
|
||||
#### Summary Card
|
||||
- **Outlined card** showing all intake details for final review:
|
||||
- Material name, brand, weight, location, status, tracking ID
|
||||
- QR code preview (small, 80dp) — auto-generated from tracking ID
|
||||
- "Print Label" toggle switch (default: ON on kiosk, OFF on mobile)
|
||||
- When ON: Bluetooth thermal printer will produce label after completion
|
||||
- Kiosk default ON because label printer is always connected
|
||||
- Mobile default OFF because printer may not be available
|
||||
|
||||
#### Update State Elements
|
||||
|
||||
| Element | Description |
|
||||
|---------|-------------|
|
||||
| Primary CTA | **"Complete Intake"** filled button → finalizes spool creation |
|
||||
| Secondary Actions | **"Back"** outlined button → returns to Identify state |
|
||||
| Key Components | Weight stepper, Quick-select chips, Location dropdown, Status toggle, Summary card, Print label switch, QR preview |
|
||||
| Validation | Weight is required (>0). Location is optional. Confirm button always enabled once weight is set. |
|
||||
|
||||
#### Completion Flow
|
||||
1. User taps "Complete Intake"
|
||||
2. API call creates spool record
|
||||
3. If "Print Label" is ON: send QR to thermal printer
|
||||
4. **Success state:** Step indicator animates all 3 steps complete + confetti-free success animation + "Spool Added ✓" snackbar
|
||||
5. **Two options appear:**
|
||||
- "Add Another Spool" → resets to Scan state (fastest path for batch intake)
|
||||
- "View Spool" → navigates to Spool Detail View for the newly created spool
|
||||
6. Auto-return to Inventory List after 10s if no action taken (kiosk only — prevents abandoned sessions)
|
||||
|
||||
### States
|
||||
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| **Default** | All fields at defaults, "Complete Intake" enabled once weight is set |
|
||||
| **Weight invalid** | Error text "Enter a weight between 50g and 5000g", button disabled |
|
||||
| **Location unavailable** | Dropdown shows "No available locations" + "Add as Unassigned" option |
|
||||
| **Submitting** | "Complete Intake" shows `mat-spinner` inline, disabled. Other elements non-interactive. |
|
||||
| **Success** | Green check animation, "Spool Added ✓", two option buttons |
|
||||
| **Error (API)** | Error snackbar + "Retry" action button. Form state preserved. |
|
||||
| **Error (Printer)** | Success still shows, but printer error snackbar: "Label print failed — reprint from spool detail" |
|
||||
|
||||
---
|
||||
|
||||
## 4. UX Rationale
|
||||
|
||||
### Scan State
|
||||
|
||||
1. **Camera-first on mobile.** The phone's camera is the fastest barcode scanner available. No typing, no selecting — just point and scan. The animated viewport frame provides clear guidance on where to aim.
|
||||
|
||||
2. **USB scanner-first on kiosk.** The kiosk doesn't need a camera — the USB HID scanner is faster and more reliable than camera-based scanning. The "Scanner Ready" state gives immediate confidence that the hardware is working.
|
||||
|
||||
3. **Auto-advance after scan.** When a barcode is detected, the system should advance automatically. The 1.5s delay lets the user see what was scanned, but doesn't require a manual "next" tap. Speed matters when processing 20 spools in a row.
|
||||
|
||||
4. **Manual entry always available.** Barcodes get damaged. Camera angles are sometimes wrong. The manual fallback prevents workflow dead-ends.
|
||||
|
||||
### Identification State
|
||||
|
||||
5. **Confidence indicator reduces anxiety.** When a barcode is scanned, the user needs to know: "Did the system recognize this?" The confidence bar answers this instantly. High confidence → "just confirm." Low confidence → "you'll need to fill in details." This sets expectations.
|
||||
|
||||
6. **Pre-fill everything possible.** If the barcode matches a known product (UPC database or previous spools), pre-fill material, brand, and color. The user should confirm, not construct. Every pre-filled field is saved time.
|
||||
|
||||
7. **Color as swatches, not dropdown.** Filament color is visual — selecting from a text dropdown ("Red" vs "Crimson" vs "Scarlet") is ambiguous. Circular swatches are unambiguous and faster to scan.
|
||||
|
||||
8. **Finish/Modifier as chips.** These are small, mutually exclusive option sets. Chips are more glanceable and tappable than dropdowns for 2-6 options.
|
||||
|
||||
### Update State
|
||||
|
||||
9. **Weight stepper + quick-select.** Operators know spool weights (they're standard sizes). Quick-select chips (250g, 500g, 750g, 1000g) cover 90% of cases in a single tap. The stepper handles the remaining 10% where weight is non-standard.
|
||||
|
||||
10. **Location as optional.** New spools may not be immediately assigned to a printer. Forcing location assignment would create friction — some operators prefer to intake first, assign later.
|
||||
|
||||
11. **Summary card for confidence.** Before committing, the user sees everything in one place. This is the "receipt" pattern — it catches errors before they become data problems. The QR preview is especially important: operators verify the physical label will match.
|
||||
|
||||
12. **"Add Another" as primary post-completion action.** In a batch intake session (which is common), the operator wants to immediately scan the next spool. "Add Another" should be the most prominent post-completion option.
|
||||
|
||||
13. **Print label default differs by platform.** On kiosk, the thermal printer is connected and labels are expected. On mobile, the printer may be remote. The defaults reflect reality.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
### Typography (MD3 Type Scale)
|
||||
|
||||
| Role | Token | Size | Weight | Line Height |
|
||||
|------|-------|------|--------|-------------|
|
||||
| App Bar Title | `titleLarge` | 22sp | 400 | 28sp |
|
||||
| Step Labels | `labelSmall` | 11sp | 500 | 16sp |
|
||||
| Section Headers | `titleSmall` | 14sp | 500 | 20sp |
|
||||
| Field Labels | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| Field Values | `bodyLarge` | 16sp | 400 | 24sp |
|
||||
| Weight Display | `headlineMedium` | 28sp | 400 | 36sp |
|
||||
| Confidence Label | `labelLarge` | 14sp | 500 | 20sp |
|
||||
| Confidence % | `titleMedium` | 16sp | 500 | 24sp |
|
||||
| Scanned Code | `labelMedium` (monospace) | 12sp | 500 | 16sp |
|
||||
| Summary Lines | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| Tracking ID | `labelMedium` (monospace) | 12sp | 500 | 16sp |
|
||||
| CTA Button | `labelLarge` | 14sp | 500 | 20sp |
|
||||
| Instruction Text | `bodyMedium` | 14sp | 400 | 20sp |
|
||||
| Quick-Select Chip | `labelLarge` | 14sp | 500 | 20sp |
|
||||
|
||||
### Spacing (MD3 8dp grid)
|
||||
|
||||
| Element | Spacing |
|
||||
|---------|---------|
|
||||
| App Bar padding | 16dp horizontal, 12dp vertical |
|
||||
| Step indicator padding | 24dp horizontal, 12dp vertical |
|
||||
| Camera viewport | Full-bleed (0dp margin), 16dp rounded corners, overflow hidden |
|
||||
| Section gap | 16dp vertical |
|
||||
| Field label to input | 8dp |
|
||||
| Color swatch grid gap | 12dp |
|
||||
| Quick-select chip gap | 8dp |
|
||||
| Weight stepper internal | 12dp between elements |
|
||||
| Summary card padding | 16dp |
|
||||
| Button row gap | 12dp |
|
||||
| Bottom padding (before nav bar) | 16dp (mobile) / 0dp (kiosk — no bottom nav) |
|
||||
|
||||
### Color (MD3 Dark Theme — "Industrial Maker")
|
||||
|
||||
Same base palette as FIL-001 and FIL-002. Screen-specific additions:
|
||||
|
||||
| Role | Token | Value (Dark) | Usage |
|
||||
|------|-------|-------------|-------|
|
||||
| Camera Viewport BG | `surfaceContainerHighest` | `#36343B` | Camera area background (before camera starts) |
|
||||
| Scan Frame | `primary` | `#A8CEDA` | Animated scanning rectangle |
|
||||
| Scan Success Flash | Custom `success` | `#8BD0A0` | Flash on successful scan |
|
||||
| Confidence High | Custom `success` | `#8BD0A0` | High confidence bar |
|
||||
| Confidence High Container | Custom `successContainer` | `#00522E` | High confidence bar background |
|
||||
| Confidence Medium | Custom `warning` | `#FFD580` | Medium confidence bar |
|
||||
| Confidence Medium Container | Custom `warningContainer` | `#5D4200` | Medium confidence bar background |
|
||||
| Confidence Low | Custom orange | `#FFB784` | Low confidence bar |
|
||||
| Confidence Low Container | Custom orange container | `#5D3A00` | Low confidence bar background |
|
||||
| Confidence Unknown | `error` | `#F2B8B5` | Unknown/no match bar |
|
||||
| Confidence Unknown Container | `errorContainer` | `#8C1D18` | Unknown bar background |
|
||||
| Pre-filled Field Tint | `primaryContainer` | `#004D63` | Subtle green-blue tint on pre-filled inputs |
|
||||
| Quick-Select Active | `primaryContainer` | `#004D63` | Active chip fill |
|
||||
| Scanner Connected | Custom `success` | `#8BD0A0` | Kiosk scanner status |
|
||||
| Scanner Disconnected | `error` | `#F2B8B5` | Kiosk scanner status |
|
||||
|
||||
### Scan Frame Animation
|
||||
|
||||
The scanning rectangle uses a **sweep line** animation:
|
||||
- Horizontal line travels from top to bottom of the frame over 2 seconds
|
||||
- Line color: `primary` (#A8CEDA) with 40% opacity
|
||||
- Line width: 2dp
|
||||
- Frame corner brackets: 3dp stroke, `primary` color
|
||||
- On code detected: Frame corners pulse green (#8BD0A0) once, scan line stops
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsiveness
|
||||
|
||||
### Kiosk (800×480) — Scan State
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ ✕ Smart Intake │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ ● ──── ○ ──── ○ │
|
||||
│ │ Scan Identify Update │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 📷 SCANNER CONNECTED ✓ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Scan a barcode or QR code... │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ ═══════════════════ │ │
|
||||
│ │ │ (pulsing line) │ │
|
||||
│ │ │ │ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Or: [Enter Barcode Manually] │
|
||||
│ │ │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Kiosk (800×480) — Identify State
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ ✕ Smart Intake │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ ✓ ────● ──── ○ │
|
||||
│ │ Scan Identify Update │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ Scanned: 8901234567890 HIGH ▓▓▓▓▓▓▓▓ 92% │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ Material: [PLA Basic ▾] │
|
||||
│ │ Brand: [Bambu Lab ▾] │
|
||||
│ │ Color: ● ● ● ● ● ● ● ● ● [C] │
|
||||
│ │ Finish: [Basic][Matte][Silk] Mod: [None] │
|
||||
│ │ │
|
||||
│ │ [Rescan] [Confirm →] │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Two-column layout for Material/Brand** on kiosk (side by side to save vertical space)
|
||||
- **Finish/Modifier on same row** to save space
|
||||
- **Color picker horizontal** with wrapping
|
||||
|
||||
### Kiosk (800×480) — Update State
|
||||
|
||||
```
|
||||
┌──────┬──────────────────────────────────────────────┐
|
||||
│ NAV │ ✕ Smart Intake │
|
||||
│ RAIL │──────────────────────────────────────────────│
|
||||
│ │ ✓ ────✓ ──── ● │
|
||||
│ │ Scan Identify Update │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ Weight: [-] 1000 [+] g │
|
||||
│ │ Quick: [250] [500] [750] [1000] │
|
||||
│ │ Location: [AMS 2, Slot A3 ▾] │
|
||||
│ │ Status: [● Available] [○ In Use] │
|
||||
│ │──────────────────────────────────────────────│
|
||||
│ │ ┌──────────────────────────────────┐ │
|
||||
│ │ │ PLA Basic - Matte Black │ │
|
||||
│ │ │ Bambu Lab • 1000g • Slot A3 │ │
|
||||
│ │ │ EXT-2026-PLA-0043 QR ████ │ │
|
||||
│ │ │ Print Label [═══●═══] ON │ │
|
||||
│ │ └──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ [◀ Back] [✓ Complete Intake] │
|
||||
└──────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **All content fits above fold** on kiosk — no scrolling needed
|
||||
- **Summary card compact** — inline with QR preview
|
||||
- **Buttons full-width** in action row
|
||||
|
||||
### Mobile PWA (375×812) — Scan State
|
||||
|
||||
As shown in Section 3 (full-bleed camera). Key differences:
|
||||
- Camera takes ~60% of screen height
|
||||
- Step indicator + instruction text in remaining space
|
||||
- Scan result chip floats above camera viewport
|
||||
|
||||
### Mobile PWA (375×812) — Identify State
|
||||
|
||||
As shown in Section 3. Key differences:
|
||||
- **Single column** layout — all fields stack vertically
|
||||
- **Scrollable** — Identification form exceeds screen height on small phones
|
||||
- **Color picker** wraps to 2 rows
|
||||
- **Buttons** fixed at bottom (sticky)
|
||||
|
||||
### Mobile PWA (375×812) — Update State
|
||||
|
||||
As shown in Section 3. Key differences:
|
||||
- **Scrollable** content
|
||||
- **Summary card** full-width
|
||||
- **Buttons** fixed at bottom (sticky)
|
||||
|
||||
### Key Adaptations
|
||||
|
||||
| Property | Kiosk (800×480) | Mobile (375×812) |
|
||||
|----------|-----------------|-------------------|
|
||||
| Scan input method | USB HID scanner | Device camera |
|
||||
| Scanner status card | Yes (connected/disconnected) | No (camera instead) |
|
||||
| Camera viewport | None | Full-bleed, ~60% height |
|
||||
| Form layout | Two-column where possible | Single column, scrollable |
|
||||
| Color picker | Horizontal with wrapping | Horizontal with wrapping (narrower) |
|
||||
| Quick-select chips | Larger (48dp height) | Standard (36dp height) |
|
||||
| Print label default | ON | OFF |
|
||||
| Post-completion auto-return | 10s → Inventory | No auto-return |
|
||||
| Bottom navigation | No (has rail) | Yes (80dp) |
|
||||
| Sticky buttons | At bottom of content | At bottom above nav bar |
|
||||
|
||||
---
|
||||
|
||||
## 7. Developer Handoff Notes
|
||||
|
||||
### Angular Material Components
|
||||
|
||||
| UI Element | Angular Material Component | Notes |
|
||||
|-----------|--------------------------|-------|
|
||||
| Top App Bar | `<mat-toolbar>` | Simple, non-collapsible. Close button on left. |
|
||||
| Step Indicator | Custom component | 3-dot stepper with animated connecting lines. Use `@angular/animations` for line fill. |
|
||||
| Camera Viewport | `<video>` + overlay `<div>` | Use `@angular/cdk/overlay` for scanning frame. Camera via `navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })`. |
|
||||
| Scanning Frame | Custom overlay | SVG or absolute-positioned divs for corner brackets. CSS animation for sweep line. |
|
||||
| Confidence Bar | Custom component | Horizontal bar with dynamic color + width. `<div>` with `[style.width]` binding. |
|
||||
| Material Selector | `<mat-select>` | With `<mat-option>`. Add `matSelectSearch` for searchable dropdown. |
|
||||
| Brand Selector | `<mat-select>` | Same as Material, plus "Other…" option with conditional `<input matInput>`. |
|
||||
| Color Picker | Custom component | Grid of circular `<button>` elements. Selected state: ring border. |
|
||||
| Finish Chips | `<mat-chip-listbox>` | Single-select. `mat-chip-option` elements. |
|
||||
| Modifier Chips | `<mat-chip-listbox>` | Single-select. Include "None" as default option. |
|
||||
| Weight Stepper | Custom component | `<button mat-icon-button>` (–/+) flanking `<input matInput type="number">`. |
|
||||
| Quick-Select Chips | `<mat-chip-listbox>` | Single-select. Values: 250, 500, 750, 1000. |
|
||||
| Location Selector | `<mat-select>` | Hierarchical with `<mat-optgroup>` for AMS/Shelves. |
|
||||
| Status Toggle | `<mat-chip-listbox>` | Two options: Available, In Use. |
|
||||
| Summary Card | `<mat-card>` outlined | Read-only display. Binding from form values. |
|
||||
| QR Preview | `qrcode` npm package | Small preview (80dp). Canvas or SVG rendering. |
|
||||
| Print Label Switch | `<mat-slide-toggle>` | Default ON (kiosk) / OFF (mobile). |
|
||||
| Complete Intake Button | `<button mat-raised-button>` | `color="primary"`. Shows `<mat-spinner>` inline when submitting. |
|
||||
| Rescan / Back | `<button mat-stroked-button>` | Navigates to previous state. |
|
||||
| Success State | Custom component | Green checkmark animation + option buttons. |
|
||||
| Snackbar | `<mat-snackbar>` | For success/error messages. Duration: 4s. |
|
||||
| Confirmation Dialog | `<mat-dialog>` | "Discard intake? Unsaved data will be lost." Cancel / Discard. |
|
||||
|
||||
### Barcode/QR Scanning Implementation
|
||||
|
||||
**Mobile (Camera):**
|
||||
- Use `@zxing/browser` or `html5-qrcode` library for camera-based barcode detection
|
||||
- Configure for: EAN-13, UPC-A, Code-128, QR, Data Matrix
|
||||
- Continuous scan mode (not snapshot) for faster detection
|
||||
- On detection: debounce 500ms to prevent duplicate reads
|
||||
- Vibrate on successful scan (if `Vibration API` available)
|
||||
|
||||
**Kiosk (USB HID Scanner):**
|
||||
- USB HID barcode scanners appear as keyboard input — they type the code + Enter
|
||||
- Listen for rapid key sequence ending in Enter on the Scan state
|
||||
- Buffer keystrokes; when Enter detected, treat buffered text as scanned code
|
||||
- Reset buffer on any pause >100ms between keystrokes
|
||||
- Debounce: ignore duplicate codes within 2s window
|
||||
|
||||
### Interaction Notes
|
||||
|
||||
1. **Auto-advance from Scan → Identify:** 1.5s delay after successful scan with a cancel affordance (tap scan result chip to cancel auto-advance).
|
||||
2. **Confidence-based pre-fill:** On entering Identify state, call API with scanned code. If match found, pre-fill form fields. Mark pre-filled fields with subtle `primaryContainer` background tint.
|
||||
3. **Weight stepper long-press:** Long-press on +/- changes step size (50→10 or 50→100). Visual feedback: tooltip showing current step size.
|
||||
4. **Quick-select chip sync:** Tapping a quick-select chip updates the stepper value and vice versa. They're two inputs to the same model.
|
||||
5. **Form validation (Identify):** "Confirm" button is disabled until Material and Color are set. Brand is strongly recommended but not blocking (can be "Unknown").
|
||||
6. **Form validation (Update):** "Complete Intake" button is disabled until Weight > 0.
|
||||
7. **Discard confirmation:** Back/close mid-flow triggers dialog: "Discard intake? This spool won't be saved." Cancel / Discard.
|
||||
8. **Post-completion "Add Another":** Resets all form state, returns to Scan state. Does NOT re-initialize camera (keep it running throughout session for speed).
|
||||
9. **Printer error handling:** If label printing fails, show snackbar but don't block the success state. The spool is already saved; label can be reprinted from Spool Detail.
|
||||
10. **Camera permission denied:** Show clear error with "Open Settings" link. Also show "Enter manually" as fallback.
|
||||
|
||||
### Accessibility
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|---------------|
|
||||
| Step indicator | `role="progressbar"`, `aria-valuenow="1/2/3"`, `aria-valuemin="1"`, `aria-valuemax="3"`, `aria-label="Step 1 of 3: Scan"` |
|
||||
| Camera viewport | `aria-label="Camera view for barcode scanning"`, `role="application"`, `aria-live="polite"` for scan result announcements |
|
||||
| Scanner status (kiosk) | `aria-live="polite"` — announces "Scanner connected" or "Scanner disconnected" |
|
||||
| Scan result | `aria-live="assertive"` — "Barcode 8901234567890 detected" |
|
||||
| Confidence bar | `role="progressbar"`, `aria-valuenow="92"`, `aria-label="Match confidence: 92 percent, High"` |
|
||||
| Material/Brand selects | Standard `mat-select` accessibility — `aria-label`, keyboard navigable |
|
||||
| Color picker | Each swatch: `aria-label="Black"`, `role="radio"`, `aria-checked="true/false"`. Radio group semantics. |
|
||||
| Weight stepper | `aria-label="Weight in grams"`, `role="spinbutton"`, `aria-valuenow`, `aria-valuemin="50"`, `aria-valuemax="5000"` |
|
||||
| Quick-select chips | `role="radiogroup"`, each chip `role="radio"`, `aria-label="500 grams"` |
|
||||
| Complete Intake button | `aria-label="Complete spool intake"` — changes to "Submitting…" during submission |
|
||||
| Close confirmation | `aria-labelledby` dialog title, `aria-describedby` dialog content. Focus trap. |
|
||||
| Motion reduction | Disable scan line animation, use static frame. Disable auto-advance (require manual "Next"). |
|
||||
| Keyboard flow | Tab: Close → Step indicator → Content → Buttons. Enter to activate. Escape to dismiss/close. |
|
||||
|
||||
### SignalR Integration
|
||||
|
||||
- After "Complete Intake" API call succeeds, the new spool is pushed to all connected clients via `SpoolAdded` hub event.
|
||||
- The Inventory List (FIL-001) will receive this event and add the spool with highlight animation.
|
||||
- If the user navigates to Inventory after intake, the new spool is already in the list — no manual refresh needed.
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- Camera stream: request `640×480` resolution for barcode scanning (sufficient, saves battery/CPU)
|
||||
- Keep camera stream alive across "Add Another" cycles — only initialize once per session
|
||||
- Debounce API calls in Identify state: don't call the barcode lookup API more than once per 2s
|
||||
- Use `OnPush` change detection for all Smart Intake components
|
||||
- QR preview generation should be non-blocking — render in a `requestAnimationFrame` callback
|
||||
|
||||
### Routing
|
||||
|
||||
```
|
||||
/intake → Redirect to /intake/scan
|
||||
/intake/scan → Scan State
|
||||
/intake/identify → Identification State
|
||||
/intake/update → Update State
|
||||
```
|
||||
|
||||
Each state is a separate route. This enables:
|
||||
- Browser back button works naturally (Update → Identify → Scan)
|
||||
- Deep-linking to specific states (e.g., for testing)
|
||||
- URL reflects current step (clear for the user and for analytics)
|
||||
BIN
design/hardware_setup.png
Normal file
|
After Width: | Height: | Size: 744 KiB |
BIN
design/homepage-mockup-kiosk.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
design/homepage-mockup-mobile.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
510
design/homepage-spec.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Extrudex — Homepage (Main Hub) UI/UX Specification
|
||||
|
||||
> **Author:** Sketch (Design Lead)
|
||||
> **Date:** April 20, 2026
|
||||
> **Version:** 1.0
|
||||
> **Status:** Ready for Review → Implementation Handoff
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
**Who is it for?**
|
||||
Workshop operators and print managers who interact with Extrudex primarily on a Raspberry Pi 5 kiosk touchscreen (arm's length, greasy hands, ambient workshop lighting) and secondarily via a mobile PWA browser for remote monitoring.
|
||||
|
||||
**What task are they completing?**
|
||||
The Homepage serves as the **command center** — the single screen where a user can, at a glance:
|
||||
|
||||
1. **Assess the fleet** — See the real-time status of every printer (printing, idle, paused, error, offline).
|
||||
2. **Spot problems fast** — Instantly identify which machines need attention (errors, paused jobs, low filament).
|
||||
3. **Take action** — Navigate to a specific printer, spool, or print job with minimal taps.
|
||||
4. **Monitor inventory** — Get a quick read on filament stock levels and recent consumption.
|
||||
5. **Recent activity** — See the last few completed or failed prints for situational awareness.
|
||||
|
||||
**Success metric:** A user walking past the kiosk can determine fleet health in under 2 seconds without touching the screen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Screen Inventory
|
||||
|
||||
The Homepage/Main Hub is a **single screen** that serves as the root of the navigation tree. It does not itself contain sub-screens, but it is the gateway to:
|
||||
|
||||
| Destination Screen | Triggered By | Priority |
|
||||
|---|---|---|
|
||||
| **Printer Detail** | Tapping any printer card | Primary |
|
||||
| **Spool Inventory** | Nav tab "Spools" | Primary |
|
||||
| **Print Jobs** | Nav tab "Prints" | Primary |
|
||||
| **Materials DB** | Nav tab "Materials" | Secondary |
|
||||
| **Settings** | Nav tab "Settings" | Secondary |
|
||||
| **Quick Scan** | Floating action button (FAB) | Primary |
|
||||
|
||||
The Homepage itself contains these **in-page sections** (not separate screens):
|
||||
|
||||
1. **Status Summary Bar** — Fleet-wide health at a glance
|
||||
2. **Printer Fleet Grid** — Live status cards for each printer
|
||||
3. **Filament Stock Snapshot** — Inventory overview with low-stock alerts
|
||||
4. **Recent Activity Feed** — Last 5 print events
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Specification
|
||||
|
||||
### Page Title
|
||||
**"Extrudex"** — Always visible in the top app bar. No subtitle needed; this is the root screen.
|
||||
|
||||
### Navigation Structure
|
||||
|
||||
**Primary Navigation: Bottom Navigation Bar (Mobile/Kiosk)**
|
||||
Material Design 3 bottom navigation with 5 destinations:
|
||||
|
||||
| Tab | Icon (Material Symbols) | Label | Badge? |
|
||||
|---|---|---|---|
|
||||
| **Hub** | `dashboard` | Hub | — (active by default) |
|
||||
| **Printers** | `print` | Printers | Error count badge |
|
||||
| **Spools** | `inventory_2` | Spools | Low-stock badge |
|
||||
| **Prints** | `receipt_long` | Prints | — |
|
||||
| **Settings** | `settings` | Settings | — |
|
||||
|
||||
**Rationale for bottom nav:**
|
||||
- Touch-first: bottom nav is the easiest reach zone on a touchscreen kiosk or phone.
|
||||
- 5 tabs is the MD3 maximum — keeps things scannable.
|
||||
- "Hub" is the homepage; the other four are top-level destinations.
|
||||
- Badges on Printers/Spools draw attention to problems without requiring navigation.
|
||||
|
||||
**Desktop/Navigation Rail (Future)**
|
||||
On wider screens (browser dashboard), the bottom nav converts to a Material 3 Navigation Rail on the left edge with the same 5 destinations. This is a responsive transformation, not a separate navigation system.
|
||||
|
||||
### Main Sections (Top to Bottom)
|
||||
|
||||
#### A. Top App Bar
|
||||
- **Leading:** Extrudex logo mark (gear icon + "EXTRUDEX" in Inter Bold)
|
||||
- **Trailing:**
|
||||
- Connection status indicator (green dot = SignalR connected, red dot = disconnected)
|
||||
- Clock (kiosk mode — always visible so users know the time without looking at their phone)
|
||||
- **Style:** MD3 medium top app bar, `surface` color background, no elevation (flat)
|
||||
- **Height:** 64px kiosk / 56px mobile
|
||||
|
||||
#### B. Status Summary Bar
|
||||
A horizontal strip below the app bar showing fleet-wide metrics in a single row:
|
||||
|
||||
| Metric | Format | Color Logic |
|
||||
|---|---|---|
|
||||
| Printers Active | `4 / 7` | Text only, neutral |
|
||||
| Printers in Error | `1` | Red if > 0, hidden if 0 |
|
||||
| Low Filament Spools | `2` | Yellow if > 0, hidden if 0 |
|
||||
| Prints Today | `12` | Neutral |
|
||||
|
||||
- **Layout:** Horizontal flex row, evenly spaced, each metric in a compact chip/badge
|
||||
- **Kiosk override:** Larger font (20px), more spacing between metrics
|
||||
- **Mobile:** Compact chips with icons, horizontally scrollable if overflow
|
||||
- **Tap behavior:** Tapping "Printers in Error" navigates to Printers tab filtered to errors. Tapping "Low Filament" navigates to Spools tab filtered to low stock.
|
||||
|
||||
#### C. Printer Fleet Grid
|
||||
The **heart of the homepage**. A responsive grid of printer status cards.
|
||||
|
||||
**Card Layout (each printer):**
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ [Status Dot] Printer Name │ ← Header row
|
||||
│─────────────────────────────│
|
||||
│ Current Job: Benchy #3 │ ← Job name or "Idle"
|
||||
│ [████████░░] 72% │ ← Progress bar + percentage
|
||||
│ Filament: PLA Silk │ ← Active material
|
||||
│ Remaining: ~142g │ ← Spool remaining
|
||||
│ ETA: 0h 23m │ ← Time remaining
|
||||
│─────────────────────────────│
|
||||
│ [View Detail →] │ ← Tap target / CTA
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Status Dot Colors:**
|
||||
- 🟢 Green (`#4ADE70` kiosk / `#16A34A` light) — Printing / Active
|
||||
- 🟡 Yellow (`#FACC15` kiosk / `#CA8A04` light) — Paused
|
||||
- 🔴 Red (`#F87171` kiosk / `#DC2626` light) — Error / Failed
|
||||
- ⚪ Gray (`#64748B`) — Idle / Offline
|
||||
|
||||
**Grid Behavior:**
|
||||
- Kiosk (800×480 Pi 5): 2 columns, 3–4 rows (scrollable)
|
||||
- Tablet (768px+): 3 columns
|
||||
- Mobile (< 480px): 1 column, list view
|
||||
|
||||
**Card Dimensions:**
|
||||
- Kiosk: Full-width within column, min-height 180px
|
||||
- Mobile: Full-width, min-height 140px, slightly denser
|
||||
|
||||
**Important States:**
|
||||
- **Printing (active):** Progress bar animates, card has subtle left-border accent in green
|
||||
- **Paused:** Yellow left border, progress bar frozen, "PAUSED" badge overlaid
|
||||
- **Error:** Red left border, card background shifts to `error-container` token, error message shown
|
||||
- **Idle:** Gray left border, dimmed progress area, "Ready" label
|
||||
- **Offline:** Fully dimmed card, "OFFLINE" badge, no real-time data
|
||||
|
||||
#### D. Filament Stock Snapshot
|
||||
A compact section showing inventory health:
|
||||
|
||||
**Layout:** Horizontal scrolling row of small cards or a mini-list
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ PLA │ │ PETG │ │ TPU │
|
||||
│ ████████░░ 80% │ │ ████░░░░░ 40% │ │ ██░░░░░░░ 20% │
|
||||
│ 23 spools │ │ 8 spools │ │ 3 spools ⚠️ │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
- Each card shows: Material base name, stock percentage bar, spool count
|
||||
- Cards with < 25% stock get a `⚠️` warning indicator and yellow-tinted background
|
||||
- **Kiosk:** 2–3 cards visible, swipe to see more
|
||||
- **Mobile:** Horizontal scroll carousel
|
||||
- **Tap:** Navigates to Spools tab filtered by that material
|
||||
|
||||
#### E. Recent Activity Feed
|
||||
The last 5 print events in a compact list:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ ✓ Benchy #3 · PLA Silk · 23g · 0h45m │
|
||||
│ ✓ Gear Set v2 · PETG Basic · 87g · 2h12m │
|
||||
│ ✗ Phone Case · TPU Basic · — · Failed │
|
||||
│ ✓ Calibration · PLA Basic · 4g · 0h05m │
|
||||
│ ✓ Bracket x4 · ASA Matte · 156g · 4h30m │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Each row: Status icon, job name, material, weight used, duration
|
||||
- Failed prints show `✗` in red with "Failed" label
|
||||
- **Kiosk:** Larger text, comfortable row height (56px)
|
||||
- **Mobile:** Standard list density (48px rows)
|
||||
- **Tap a row:** Navigates to that print's detail view
|
||||
- **"View All" link:** At bottom, navigates to Prints tab
|
||||
|
||||
### Primary CTA
|
||||
**Floating Action Button (FAB):** "Quick Scan" — a prominent FAB in the bottom-right corner with a `qr_code_scanner` icon. This triggers the barcode/USB scanner workflow for spool check-in/check-out.
|
||||
|
||||
- Kiosk: Extended FAB with label "Scan" + icon, 56×56px minimum
|
||||
- Mobile: Standard FAB, icon only, 56×56px
|
||||
- Positioned above the bottom nav, with 16px margin from edges
|
||||
|
||||
### Secondary Actions
|
||||
- Tap any printer card → Printer Detail
|
||||
- Tap any stock card → Spools filtered
|
||||
- Tap any activity row → Print detail
|
||||
- Tap error/low badges in summary bar → Filtered navigation
|
||||
|
||||
### Key Components (MD3)
|
||||
|
||||
| Component | Usage | Notes |
|
||||
|---|---|---|
|
||||
| Bottom Navigation | Primary nav | 5 destinations |
|
||||
| Top App Bar (Medium) | App header | Logo + status + clock |
|
||||
| Cards (Elevated) | Printer status | Real-time content |
|
||||
| Cards (Filled/outlined) | Filament stock | Compact, scrollable |
|
||||
| List | Activity feed | 3-line items |
|
||||
| FAB (Extended/Regular) | Quick Scan | Always accessible |
|
||||
| Badge | Nav tabs, status | Error count, low stock |
|
||||
| Linear Progress Indicator | Print progress | On printer cards |
|
||||
| Chip | Status summary | Compact metrics |
|
||||
|
||||
### Important States
|
||||
|
||||
| State | Visual Treatment |
|
||||
|---|---|
|
||||
| Loading (initial) | Skeleton screens for printer cards, shimmer animation |
|
||||
| SignalR disconnected | Red dot in app bar, banner: "Live updates paused — reconnecting..." |
|
||||
| No printers registered | Empty state illustration + "Add your first printer" CTA |
|
||||
| No spools registered | Stock section shows "No inventory — add spools to start tracking" |
|
||||
| All printers idle | Fleet grid shows all gray cards, summary bar shows "0 active" |
|
||||
| Critical error (printer) | Card pulses red border once, then steady red left border |
|
||||
|
||||
---
|
||||
|
||||
## 4. UX Rationale
|
||||
|
||||
### Why This Layout Supports the Task
|
||||
|
||||
1. **Fleet health at a glance:** The Status Summary Bar + Printer Fleet Grid are visible immediately on load. No scrolling required on kiosk to see if something is wrong. Color-coded status dots and left-border accents make the visual scan instantaneous.
|
||||
|
||||
2. **Progressive detail:** The homepage gives you "enough" — status, progress, material. You only tap into a printer card when you need details (temps, G-code, history). This avoids information overload while keeping critical data surface-level.
|
||||
|
||||
3. **Inventory awareness without navigation:** The Filament Stock Snapshot means operators don't need to leave the homepage to know if they're running low. The warning state (yellow + ⚠️) is visible without interpreting numbers.
|
||||
|
||||
4. **Activity context:** The Recent Activity Feed gives "what just happened" situational awareness — useful when returning to the kiosk after being away. It answers "did that print finish?" without requiring navigation.
|
||||
|
||||
5. **Scan-first workflow:** The FAB for Quick Scan is always accessible from the homepage, which is the most common action for spool check-in during workshop operations. Placed bottom-right — the natural resting thumb zone.
|
||||
|
||||
### Hierarchy (What matters most → least)
|
||||
|
||||
1. **Printer errors** (red, draws the eye immediately)
|
||||
2. **Active printers with progress** (green accent, animated progress bars)
|
||||
3. **Paused printers** (yellow accent, needs attention)
|
||||
4. **Low filament warnings** (yellow indicators in stock section)
|
||||
5. **Idle printers** (gray, calm, no action needed)
|
||||
6. **Recent activity** (informational, below the fold on mobile)
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
| Decision | Benefit | Cost | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Bottom nav over side nav | Touch-friendly, MD3 standard, always visible | Takes screen space at bottom | Acceptable on touch-first device |
|
||||
| Cards over table | Glanceable, status-colorable, touch-friendly | Less dense (can't see 7 printers at once) | Grid layout maximizes visible count; scroll for 6+ printers |
|
||||
| Summary bar metrics | Fleet health without scrolling | Adds cognitive load if too many metrics | Keep to 4 max, hide zero-value metrics |
|
||||
| Horizontal scroll for stock cards | Saves vertical space, works well for 3–6 materials | Hidden cards require swipe | First 3 most-used materials visible; swipe for rest |
|
||||
| FAB for scan | Always accessible, one-tap | Covers content behind it | Standard FAB behavior; scrolls away on mobile |
|
||||
| No sidebar on kiosk | Maximizes kiosk screen real estate | No persistent deep-nav | Bottom nav is sufficient for 5 top-level destinations |
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
### Tone
|
||||
**Modern Industrial/Maker** — Dark mode by default on kiosk. Professional workshop vibe. High contrast. Think: a CNC control panel, not a social media app. The aesthetic should feel like a tool you trust, not an app you browse.
|
||||
|
||||
### Dark Mode Palette (Kiosk Default)
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `md.sys.color.background` | `#0F172A` | Page background |
|
||||
| `md.sys.color.surface` | `#1E293B` | Card backgrounds, nav bar |
|
||||
| `md.sys.color.surface-container` | `#334155` | Elevated cards, containers |
|
||||
| `md.sys.color.surface-container-high` | `#475569` | Hover/pressed states |
|
||||
| `md.sys.color.primary` | `#60A5FA` | Primary actions, links, active tab |
|
||||
| `md.sys.color.on-primary` | `#0F172A` | Text on primary |
|
||||
| `md.sys.color.primary-container` | `#1E3A5F` | Subtle primary tinted containers |
|
||||
| `md.sys.color.error` | `#F87171` | Error states, failed prints |
|
||||
| `md.sys.color.error-container` | `#450A0A` | Error card backgrounds |
|
||||
| `md.sys.color.on-error` | `#FFFFFF` | Text on error |
|
||||
| `md.sys.color.on-surface` | `#F1F5F9` | Primary text |
|
||||
| `md.sys.color.on-surface-variant` | `#94A3B8` | Secondary text |
|
||||
| `md.sys.color.outline` | `#334155` | Borders, dividers |
|
||||
| `md.sys.color.outline-variant` | `#1E293B` | Subtle borders |
|
||||
|
||||
### Light Mode Palette (Dashboard/Future)
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `md.sys.color.background` | `#F8FAFC` | Page background |
|
||||
| `md.sys.color.surface` | `#FFFFFF` | Card backgrounds |
|
||||
| `md.sys.color.primary` | `#2563EB` | Primary actions |
|
||||
| `md.sys.color.on-surface` | `#0F172A` | Primary text |
|
||||
| `md.sys.color.on-surface-variant` | `#475569` | Secondary text |
|
||||
|
||||
### Typography
|
||||
|
||||
| Role | Font | Weight | Size (Kiosk) | Size (Mobile) |
|
||||
|---|---|---|---|---|
|
||||
| App Title | Inter | Bold | 22px | 20px |
|
||||
| Card Title (Printer Name) | Inter | SemiBold | 20px | 18px |
|
||||
| Section Heading | Inter | SemiBold | 18px | 16px |
|
||||
| Body Text | Inter | Regular | 18px | 16px |
|
||||
| Metric Value | Inter | Bold | 28px | 24px |
|
||||
| Metric Label | Inter | Medium | 14px | 12px |
|
||||
| Mono Values (weight, time, %) | JetBrains Mono | Medium | 18px | 16px |
|
||||
| Caption / Timestamp | Inter | Regular | 14px | 12px |
|
||||
|
||||
**Rationale for oversized metrics:** The kiosk is viewed at arm's length or further. A 28px bold metric is legible from 3–4 feet away. Monospace for numeric values ensures columns align and feels "instrument-like."
|
||||
|
||||
### Spacing / Density
|
||||
|
||||
| Token | Kiosk | Mobile |
|
||||
|---|---|---|
|
||||
| Page padding | 24px | 16px |
|
||||
| Card padding | 20px | 16px |
|
||||
| Grid gap | 16px | 12px |
|
||||
| Section gap | 32px | 24px |
|
||||
| Row height (lists) | 56px | 48px |
|
||||
| FAB margin from edge | 16px | 16px |
|
||||
| Touch target minimum | 48px | 44px |
|
||||
|
||||
### Component Behavior
|
||||
|
||||
- **Printer cards:** Update in real-time via SignalR. Progress bar animates smoothly (CSS transition 300ms). Status changes trigger a brief highlight flash (200ms background shift) to draw attention.
|
||||
- **Bottom nav:** Active tab has `primary` color icon + label. Inactive tabs use `on-surface-variant`. Badges are small circles positioned at the icon's top-right.
|
||||
- **FAB:** Elevated with shadow-3. On tap, ripple effect. Extended label appears on kiosk (wider screen), icon-only on mobile.
|
||||
- **Status summary bar:** Metric chips are non-scrolling on kiosk (fits in one row at 800px width), horizontally scrolling on mobile if needed.
|
||||
- **Activity feed:** List items have subtle divider lines (`outline-variant`). Failed items have `error-container` background tint.
|
||||
- **Skeleton loading:** Cards show shimmer placeholder during initial load (1.5s max before content appears).
|
||||
|
||||
### Color Usage Guidelines
|
||||
|
||||
1. **Color is information, not decoration.** Every colored element must communicate state.
|
||||
2. **Red = immediate attention.** Only use for errors, failures, disconnections. Never for decoration.
|
||||
3. **Yellow = needs attention soon.** Paused printers, low filament, warnings.
|
||||
4. **Green = operating normally.** Active printing, completed, connected.
|
||||
5. **Blue = interactive.** Links, buttons, active navigation, primary CTAs.
|
||||
6. **Gray = neutral/idle.** Offline printers, inactive states, secondary info.
|
||||
7. **Never use color alone to convey meaning.** Always pair with icon + text label (accessibility).
|
||||
|
||||
---
|
||||
|
||||
## 6. Responsiveness
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Breakpoint | Width | Layout |
|
||||
|---|---|---|
|
||||
| Kiosk | 480–800px (Pi 5 typical: 800×480) | 2-col grid, bottom nav, extended FAB |
|
||||
| Mobile (compact) | < 480px | 1-col list, bottom nav, standard FAB |
|
||||
| Tablet | 768–1024px | 3-col grid, bottom nav, extended FAB |
|
||||
| Desktop | 1024px+ | 3-col grid, nav rail, extended FAB |
|
||||
|
||||
### Layout Changes by Device
|
||||
|
||||
#### Pi 5 Kiosk (800×480, Landscape)
|
||||
- **Top app bar:** 64px height, clock always visible
|
||||
- **Status summary:** 4 metrics in a single row (fits 800px)
|
||||
- **Printer grid:** 2 columns × N rows, scrollable vertically
|
||||
- **Stock snapshot:** 3 cards visible, no scroll needed (most common materials first)
|
||||
- **Activity feed:** 3 rows visible, "View All" link
|
||||
- **Bottom nav:** Full 5 tabs
|
||||
- **FAB:** Extended with "Scan" label
|
||||
|
||||
#### Mobile PWA (< 480px, Portrait)
|
||||
- **Top app bar:** 56px height, no clock (use phone clock)
|
||||
- **Status summary:** 2–3 metrics visible, horizontal scroll for more
|
||||
- **Printer grid:** 1 column list view (cards become horizontal list items)
|
||||
- **Stock snapshot:** Horizontal carousel, 2 cards visible
|
||||
- **Activity feed:** 3–5 rows, compact density
|
||||
- **Bottom nav:** 5 tabs (may collapse labels on very narrow screens)
|
||||
- **FAB:** Icon only, standard 56px
|
||||
|
||||
#### Tablet / Desktop Browser
|
||||
- **Bottom nav → Navigation Rail** (left side, 80px wide)
|
||||
- **Printer grid:** 3 columns
|
||||
- **Stock snapshot:** 4–5 cards visible in row
|
||||
- **Activity feed:** 5 rows
|
||||
- **Additional whitespace** — dashboard mode can breathe more
|
||||
|
||||
### Kiosk-Specific Considerations
|
||||
|
||||
- **No hover states** — all interactions are tap/click only
|
||||
- **No tooltips** — information must be visible inline
|
||||
- **Larger touch targets** — 48px minimum (4px above MD3 default)
|
||||
- **No right-click context menus** — all actions are explicit buttons/links
|
||||
- **Screen wake** — kiosk should not sleep; CSS `animation` on a subtle element to prevent screen burn-in (shift a 1px element every 60s)
|
||||
- **Overshoot scroll** — prevent pull-to-refresh or browser gestures (PWA `overscroll-behavior: none`)
|
||||
|
||||
---
|
||||
|
||||
## 7. Developer Handoff Notes
|
||||
|
||||
### Reusable Components to Build
|
||||
|
||||
| Component | Type | Props/Inputs | Notes |
|
||||
|---|---|---|---|
|
||||
| `<ex-printer-card>` | Standalone | `printer: Printer`, `status: PrinterStatus`, `job?: PrintJob` | Left-border color computed from status. SignalR drives re-renders. |
|
||||
| `<ex-status-dot>` | Atom | `status: 'active' \| 'paused' \| 'error' \| 'idle' \| 'offline'` | Small colored circle (12px) with aria-label for accessibility |
|
||||
| `<ex-metric-chip>` | Atom | `label: string`, `value: string \| number`, `variant: 'default' \| 'error' \| 'warning'` | Used in Status Summary Bar |
|
||||
| `<ex-stock-card>` | Standalone | `material: string`, `percentage: number`, `spoolCount: number`, `lowStock: boolean` | Horizontal scroll child |
|
||||
| `<ex-activity-item>` | List Item | `job: PrintEvent` | 3-line list item with status icon |
|
||||
| `<ex-progress-bar>` | Atom | `value: number`, `status: PrinterStatus` | MD3 linear progress, color matches status |
|
||||
| `<ex-fleet-summary>` | Composite | `printers: Printer[]`, `spools: Spool[]` | Renders metric chips row |
|
||||
| `<ex-connection-indicator>` | Atom | `connected: boolean` | Green/red dot in app bar |
|
||||
|
||||
### Angular Material Components Used
|
||||
|
||||
- `MatBottomNavigation` (or custom bottom nav — MD3 bottom nav is not yet in Angular Material; implement with `MatTabNav` + custom styling)
|
||||
- `MatCard` → Styled with custom dark tokens
|
||||
- `MatProgressBar` → Custom color per status
|
||||
- `MatFabButton` / `MatMiniFabButton`
|
||||
- `MatBadge` → For nav tab badges
|
||||
- `MatList` → Activity feed
|
||||
- `MatChip` → Status summary metrics
|
||||
- `MatIcon` → Material Symbols throughout
|
||||
- `MatToolbar` → Top app bar
|
||||
|
||||
### Interaction Notes
|
||||
|
||||
1. **SignalR subscriptions:** The homepage must subscribe to `PrinterHub` on mount. Each printer card re-renders on status/progress push events. Unsubscribe on navigate-away.
|
||||
2. **Optimistic updates:** Progress bars should animate smoothly — use CSS transitions, not discrete jumps. If SignalR sends a progress update every 5s, interpolate between values.
|
||||
3. **FAB scan action:** On tap, immediately focus a hidden text input that captures USB HID barcode scanner output ( scanners type characters + Enter). This is a capture-only input — no keyboard should appear on mobile. Use `<input readonly>` or intercept `keydown` globally.
|
||||
4. **Pull-to-refresh:** Disabled on kiosk. On mobile PWA, consider enabling for manual data refresh.
|
||||
5. **Navigation:** Use Angular Router with bottom nav tabs bound to routerLinkActive. Highlight active tab. Preserve scroll position when returning to Hub.
|
||||
6. **Skeleton screens:** On initial load, show 6 skeleton printer cards (matching grid layout). Use `@angular/material` skeleton patterns or custom shimmer CSS.
|
||||
|
||||
### Accessibility
|
||||
|
||||
| Requirement | Implementation |
|
||||
|---|---|
|
||||
| Screen reader | All status indicators have `aria-label` (e.g., "Printer Elegoo-1: Printing, 72% complete") |
|
||||
| Color blindness | Never color-only — always icon + text + color. Status dots have aria-labels |
|
||||
| Keyboard nav | Tab order: top bar → summary metrics → printer cards (grid order) → stock → activity → FAB → bottom nav |
|
||||
| Focus management | After navigation, focus lands on the page title. After FAB tap, focus returns to FAB |
|
||||
| Touch target | 48px minimum on kiosk, 44px minimum on mobile — all interactive elements |
|
||||
| Contrast | All text on dark background meets WCAG AA (4.5:1 for body, 3:1 for large text) |
|
||||
| Motion | Respect `prefers-reduced-motion` — disable progress bar animation and shimmer if set |
|
||||
| Live regions | Printer status changes announced via `aria-live="polite"` on the card region |
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- **OnPush change detection** for all components receiving SignalR data
|
||||
- **TrackBy** on printer grid `*ngFor` to avoid re-rendering unchanged cards
|
||||
- **Virtual scrolling** for activity feed if it grows beyond 20 items (use `@angular/cdk ScrollingModule`)
|
||||
- **Lazy load** Spools, Prints, Materials, Settings tabs — only Hub is eager-loaded
|
||||
- **Image optimization:** Use inline SVG for status icons, not raster images. Logo should be SVG.
|
||||
|
||||
### File Structure Suggestion
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── pages/
|
||||
│ └── hub/
|
||||
│ ├── hub.component.ts
|
||||
│ ├── hub.component.html
|
||||
│ ├── hub.component.scss
|
||||
│ └── hub.component.spec.ts
|
||||
├── components/
|
||||
│ ├── printer-card/
|
||||
│ ├── status-dot/
|
||||
│ ├── metric-chip/
|
||||
│ ├── stock-card/
|
||||
│ ├── activity-item/
|
||||
│ ├── progress-bar/
|
||||
│ ├── fleet-summary/
|
||||
│ └── connection-indicator/
|
||||
├── layout/
|
||||
│ ├── bottom-nav/
|
||||
│ ├── top-app-bar/
|
||||
│ └── nav-rail/
|
||||
└── shared/
|
||||
└── tokens/
|
||||
└── _extrudex-tokens.scss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Navigation Map
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ EXTRUDEX │
|
||||
│ │
|
||||
│ ┌─ Hub (Home) ──────────────────────────────────────────┐ │
|
||||
│ │ • Fleet Status Grid │ │
|
||||
│ │ • Filament Snapshot │ │
|
||||
│ │ • Recent Activity │ │
|
||||
│ │ • [FAB: Quick Scan] │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Printers ────────┐ ┌─ Spools ─────────────────────┐ │
|
||||
│ │ Printer List │ │ Spool Inventory │ │
|
||||
│ │ → Printer Detail │ │ → Spool Detail │ │
|
||||
│ │ → Job History │ │ → Check-in/Check-out │ │
|
||||
│ │ → Controls │ │ → Link to Printer │ │
|
||||
│ └────────────────────┘ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Prints ──────────┐ ┌─ Settings ───────────────────┐ │
|
||||
│ │ Print Job List │ │ System Configuration │ │
|
||||
│ │ → Print Detail │ │ Printer Management │ │
|
||||
│ │ → COGS Report │ │ User Preferences │ │
|
||||
│ └────────────────────┘ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ ══════════════════════════════════════════════════════════ │
|
||||
│ [Hub] [Printers] [Spools] [Prints] [Settings] │
|
||||
│ ══════════════════════════════════════════════════════════ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*End of Specification — Ready for Stuart (mockup generation) and Rex (implementation handoff)*
|
||||
BIN
design/kiosk_interface.png
Normal file
|
After Width: | Height: | Size: 642 KiB |
BIN
design/mockup-inventory-list-kiosk.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
design/mockup-inventory-list-mobile.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/mockup-spool-detail-kiosk.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/mockup-spool-detail-mobile.jpg
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
design/overall_dashboard.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
design/smart-intake-identify-kiosk.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
design/smart-intake-identify-mobile.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
100
design/smart-intake-identify-spec.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Design Specification: Smart Intake - Identification State
|
||||
|
||||
**Screen ID:** FIL-003-B
|
||||
**State:** 2 of 3 (Identify)
|
||||
**Context:** Occurs immediately after a successful barcode/QR scan.
|
||||
**Objective:** Validate the identity of the spool before committing it to the inventory.
|
||||
|
||||
---
|
||||
|
||||
## 1. User Experience Goals
|
||||
- **Frictionless Validation:** If the scan is high-confidence, the user should only need to tap "Confirm".
|
||||
- **Error Correction:** Provide easy paths to correct misidentified attributes without restarting the entire flow.
|
||||
- **Glanceability:** Use color-coded confidence bars so the user knows immediately if the system is "sure" or "guessing".
|
||||
|
||||
---
|
||||
|
||||
## 2. Visual Hierarchy & Layout
|
||||
|
||||
### 2.1 Global Header
|
||||
- **Top App Bar:** `md-top-app-bar` (Small).
|
||||
- **Leading:** Close icon (✕) -> Returns to Inventory with "Discard changes?" prompt.
|
||||
- **Title:** "Smart Intake"
|
||||
- **Step Indicator:** Custom linear stepper.
|
||||
- `Scan` (Completed: Green check) $\rightarrow$ `Identify` (Active: Blue filled) $\rightarrow$ `Update` (Pending: Grey outline).
|
||||
|
||||
### 2.2 Main Content Area
|
||||
The content is organized as a vertical stack of cards/inputs to ensure touch-first accessibility.
|
||||
|
||||
#### A. Confidence Card (`md-card` - filled)
|
||||
- **Label:** "Scanned: [Barcode Value]" (Monospace)
|
||||
- **Confidence Metric:** "Match: HIGH CONFIDENCE"
|
||||
- **Visual Indicator:** A linear progress bar.
|
||||
- **High (≥80%):** Success Green (`#16A34A` / `#4ADE70`)
|
||||
- **Medium (40-79%):** Warning Yellow (`#CA8A04` / `#FACC15`)
|
||||
- **Low (<40%):** Error Red (`#DC2626` / `#F87171`)
|
||||
- **Percentage:** Displayed as text (e.g., "92%") at the end of the bar.
|
||||
|
||||
#### B. Attribute Selection (Input Group)
|
||||
- **Material Selector:** `md-dropdown-select` (Searchable).
|
||||
- *Pre-fill:* Based on scan result.
|
||||
- **Brand Selector:** `md-dropdown-select` (Searchable).
|
||||
- *Pre-fill:* Based on scan result.
|
||||
- **Color Picker:** Custom circular swatch grid.
|
||||
- **Interaction:** Tap to select. Selected swatch has a high-contrast ring indicator.
|
||||
- **Custom Option:** A "Custom" button that opens a text input for unique colors.
|
||||
- **Finish & Modifier:** `md-chip-set` (Single select).
|
||||
- **Finish:** [Basic, Matte, Silk, Sparkle, Metallic, Translucent]
|
||||
- **Modifier:** [None, Carbon Fiber, Wood Fill, Glow-in-Dark, Marble, Gradient]
|
||||
|
||||
### 2.3 Action Bar (Footer)
|
||||
- **Rescan Button:** `md-outlined-button`. Transitions user back to State 1 (Scan).
|
||||
- **Confirm Button:** `md-filled-button`. Primary CTA. Transitions user to State 3 (Update).
|
||||
- *Constraint:* Disabled until Material, Brand, and Color are selected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
|
||||
### 3.1 Dimensions & Touch Targets
|
||||
- **Touch Targets:** Minimum 44x44px for all buttons, dropdowns, and swatches.
|
||||
- **Padding:** 16px internal card padding; 24px external margin between major sections.
|
||||
- **Typography:**
|
||||
- Title: `Display Small` (text-3xl font-bold)
|
||||
- Section Labels: `Label Medium` (text-sm font-medium)
|
||||
- Value Text: `Body Large` (text-base font-normal)
|
||||
|
||||
### 3.2 Theme Mapping
|
||||
| Element | Kiosk (Dark) | Mobile (Light) |
|
||||
|-----------|--------------|---------------|
|
||||
| Background | `#0F172A` | `#F8FAFC` |
|
||||
| Surface | `#1E293B` | `#FFFFFF` |
|
||||
| Primary | `#60A5FA` | `#2563EB` |
|
||||
| Text Primary | `#F1F5F9` | `#0F172A` |
|
||||
| Text Secondary| `#94A3B8` | `#475569` |
|
||||
|
||||
---
|
||||
|
||||
## 4. State Transitions
|
||||
|
||||
### Transition: Scan $\rightarrow$ Identify
|
||||
- **Trigger:** Barcode detected and decoded.
|
||||
- **Animation:**
|
||||
1. Scanning frame flashes **Green**.
|
||||
2. A "Slide-in" transition from the right (300ms ease-out).
|
||||
3. Step indicator dot for "Scan" transforms into a checkmark.
|
||||
4. Step indicator dot for "Identify" fills with Primary color.
|
||||
|
||||
### Transition: Identify $\rightarrow$ Update
|
||||
- **Trigger:** "Confirm" button tapped.
|
||||
- **Animation:**
|
||||
1. Slide-in transition from the right (300ms ease-out).
|
||||
2. Step indicator dot for "Identify" transforms into a checkmark.
|
||||
3. Step indicator dot for "Update" fills with Primary color.
|
||||
|
||||
---
|
||||
|
||||
## 5. Edge Cases & Error Handling
|
||||
- **No Match Found:** Confidence bar is Red (0%). All dropdowns are empty. System displays a hint: "Barcode unknown. Please enter details manually."
|
||||
- **Ambiguous Match:** Confidence bar is Yellow. Material and Brand are pre-filled, but Color is highlighted with a "Review Required" pulse.
|
||||
- **Invalid Input:** If a user attempts to confirm without required fields, the missing fields pulse Red.
|
||||