From c1a115c93890da37101ed596f6187f06259331f4 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:09:08 +0000 Subject: [PATCH 1/5] feat(CUB-40): [Extrudex] Add cost summary API endpoint --- .../API/Controllers/PrintJobsController.cs | 86 +++++++++++++++++++ .../API/DTOs/PrintJobs/CostSummaryResponse.cs | 55 ++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 backend/API/DTOs/PrintJobs/CostSummaryResponse.cs diff --git a/backend/API/Controllers/PrintJobsController.cs b/backend/API/Controllers/PrintJobsController.cs index 226430e..5cb802e 100644 --- a/backend/API/Controllers/PrintJobsController.cs +++ b/backend/API/Controllers/PrintJobsController.cs @@ -413,6 +413,92 @@ public class PrintJobsController : ControllerBase return NoContent(); } + // ── GET /api/printjobs/{id}/cost-summary ────────────────────────── + + /// + /// Gets the material cost summary for a specific print job. + /// Calculates total material cost from filament usage (grams derived) + /// and the spool's purchase price. Returns warnings instead of errors + /// when cost data is unavailable. + /// + /// The unique identifier of the print job. + /// A cost summary with breakdown and any warnings about missing data. + /// Returns the cost summary. Warnings field lists any missing data. + /// If the print job with the given ID is not found. + [HttpGet("{id:guid}/cost-summary")] + [ProducesResponseType(typeof(CostSummaryResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCostSummary(Guid id) + { + _logger.LogDebug("Getting cost summary for print job {Id}", id); + + var job = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .FirstOrDefaultAsync(j => j.Id == id); + + if (job is null) + { + _logger.LogWarning("Print job {Id} not found for cost summary", id); + return NotFound(new { error = $"Print job with ID '{id}' not found." }); + } + + var warnings = new List(); + var spool = job.Spool; + + // Build response with what we have + var response = new CostSummaryResponse + { + PrintJobId = job.Id, + PrintName = job.PrintName, + SpoolId = job.SpoolId, + SpoolSerial = spool?.SpoolSerial ?? string.Empty, + SpoolBrand = spool?.Brand ?? string.Empty, + SpoolColorName = spool?.ColorName ?? string.Empty, + MmExtruded = job.MmExtruded, + GramsDerived = job.GramsDerived, + SpoolPurchasePrice = spool?.PurchasePrice, + SpoolWeightTotalGrams = spool?.WeightTotalGrams, + StoredCostPerPrint = job.CostPerPrint + }; + + // Validate spool data availability + if (spool is null) + { + warnings.Add("Spool data is not available for this print job. Cost cannot be calculated."); + response.Warnings = warnings; + return Ok(response); + } + + // Check if we can calculate cost + if (!spool.PurchasePrice.HasValue) + { + warnings.Add("Spool purchase price is not set. Cost per gram and total material cost cannot be calculated."); + } + + if (spool.WeightTotalGrams <= 0) + { + warnings.Add("Spool total weight is zero or invalid. Cost per gram and total material cost cannot be calculated."); + } + + // If we have enough data, calculate the cost + if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0) + { + var pricePerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; + response.PricePerGram = Math.Round(pricePerGram, 4); + response.TotalMaterialCost = Math.Round(job.GramsDerived * pricePerGram, 4); + } + + // Warn if grams derived is zero but mm extruded is non-zero + if (job.GramsDerived == 0 && job.MmExtruded > 0) + { + warnings.Add("GramsDerived is zero despite MmExtruded being non-zero. Cost may be inaccurate. Consider re-deriving grams from filament parameters."); + } + + response.Warnings = warnings; + return Ok(response); + } + // ── Gram Derivation Formula ──────────────────────────────────── /// diff --git a/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs b/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs new file mode 100644 index 0000000..0c2f9ed --- /dev/null +++ b/backend/API/DTOs/PrintJobs/CostSummaryResponse.cs @@ -0,0 +1,55 @@ +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// Response DTO for the cost summary of a print job. +/// Provides a breakdown of material cost based on filament usage +/// and spool pricing data. If cost data is incomplete, warnings +/// are returned instead of throwing an error. +/// +public class CostSummaryResponse +{ + /// Unique identifier of the print job. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// Foreign key to the spool used for this print job. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Brand of the spool. + public string SpoolBrand { get; set; } = string.Empty; + + /// Color name of the spool. + public string SpoolColorName { get; set; } = string.Empty; + + /// Total millimeters of filament extruded during this print. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print job. + public decimal GramsDerived { get; set; } + + /// Purchase price of the full spool, if available. + public decimal? SpoolPurchasePrice { get; set; } + + /// Total weight of the spool in grams when full. + public decimal? SpoolWeightTotalGrams { get; set; } + + /// Calculated price per gram (purchase price / total weight), if available. + public decimal? PricePerGram { get; set; } + + /// Calculated total material cost for this print job, if available. + public decimal? TotalMaterialCost { get; set; } + + /// The CostPerPrint stored on the print job entity, if set. + public decimal? StoredCostPerPrint { get; set; } + + /// + /// Warnings about missing data that prevent cost calculation. + /// Empty if all data is available and cost was calculated successfully. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file From 6aa31f4be30ff50c9df0fbf5e13ebf080902c4fb Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 17:57:57 +0000 Subject: [PATCH 2/5] CUB-37: implement cost-per-print calculation service --- .../API/Controllers/CostAnalysisController.cs | 108 ++++++++++++ .../API/Controllers/PrintJobsController.cs | 55 +++++- .../API/DTOs/PrintJobs/CostPerPrintDtos.cs | 99 +++++++++++ .../Domain/Interfaces/ICostPerPrintService.cs | 76 +++++++++ .../Services/CostPerPrintService.cs | 158 ++++++++++++++++++ backend/Program.cs | 3 + 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 backend/API/Controllers/CostAnalysisController.cs create mode 100644 backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs create mode 100644 backend/Domain/Interfaces/ICostPerPrintService.cs create mode 100644 backend/Infrastructure/Services/CostPerPrintService.cs diff --git a/backend/API/Controllers/CostAnalysisController.cs b/backend/API/Controllers/CostAnalysisController.cs new file mode 100644 index 0000000..87e94b0 --- /dev/null +++ b/backend/API/Controllers/CostAnalysisController.cs @@ -0,0 +1,108 @@ +using Extrudex.API.DTOs.PrintJobs; +using Extrudex.Domain.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Extrudex.API.Controllers; + +/// +/// Controller for cost analysis endpoints. Provides spool-level +/// cost breakdowns and aggregated COGS reporting. +/// +[ApiController] +[Route("api/cost-analysis")] +public class CostAnalysisController : ControllerBase +{ + private readonly ICostPerPrintService _costService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The cost-per-print calculation service. + /// The logger for diagnostic output. + public CostAnalysisController( + ICostPerPrintService costService, + ILogger logger) + { + _costService = costService; + _logger = logger; + } + + // ── POST /api/cost-analysis/spool ──────────────────────────── + + /// + /// Calculates cost breakdowns for all print jobs associated with a specific spool. + /// Returns per-job costs plus an aggregated total. Jobs with missing cost data + /// include warnings and null cost fields — the endpoint never throws for missing data. + /// + /// The request containing the spool identifier. + /// A spool-level cost summary with per-job breakdowns. + /// Returns the spool cost breakdown with per-job details. + /// If the spool has no print jobs. + [HttpPost("spool")] + [ProducesResponseType(typeof(SpoolCostResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CalculateSpoolCost([FromBody] SpoolCostRequest request) + { + _logger.LogDebug("Calculating cost breakdown for spool {SpoolId}", request.SpoolId); + + var results = await _costService.CalculateBySpoolAsync(request.SpoolId); + + if (results.Count == 0) + { + return NotFound(new { error = $"No print jobs found for spool with ID '{request.SpoolId}'." }); + } + + // Build the spool-level summary + var firstResult = results[0]; + var jobResponses = results.Select(MapCostToResponse).ToList(); + + // Aggregate total cost and grams — only include jobs that have a valid cost + var calculableJobs = results.Where(r => r.CostPerPrint.HasValue).ToList(); + var totalCost = calculableJobs.Count == results.Count + ? Math.Round(calculableJobs.Sum(r => r.CostPerPrint!.Value), 4) + : (decimal?)null; + + var aggregateWarnings = new List(); + if (calculableJobs.Count < results.Count) + { + aggregateWarnings.Add( + $"{results.Count - calculableJobs.Count} of {results.Count} print jobs have missing cost data. " + + "Total cost reflects only jobs with complete data."); + } + + var response = new SpoolCostResponse + { + SpoolId = request.SpoolId, + SpoolSerial = firstResult.SpoolSerial, + PurchasePrice = firstResult.PurchasePrice, + WeightTotalGrams = firstResult.WeightTotalGrams, + CostPerGram = firstResult.CostPerGram, + TotalGramsConsumed = results.Sum(r => r.GramsDerived), + TotalCost = totalCost, + JobCount = results.Count, + Jobs = jobResponses, + Warnings = aggregateWarnings + }; + + return Ok(response); + } + + /// + /// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO. + /// + private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new() + { + PrintJobId = r.PrintJobId, + PrintName = r.PrintName, + SpoolId = r.SpoolId, + SpoolSerial = r.SpoolSerial, + MmExtruded = r.MmExtruded, + GramsDerived = r.GramsDerived, + PurchasePrice = r.PurchasePrice, + WeightTotalGrams = r.WeightTotalGrams, + CostPerGram = r.CostPerGram, + CostPerPrint = r.CostPerPrint, + Warnings = r.Warnings + }; +} \ No newline at end of file diff --git a/backend/API/Controllers/PrintJobsController.cs b/backend/API/Controllers/PrintJobsController.cs index 226430e..5c244c3 100644 --- a/backend/API/Controllers/PrintJobsController.cs +++ b/backend/API/Controllers/PrintJobsController.cs @@ -2,6 +2,7 @@ using Extrudex.API.DTOs; using Extrudex.API.DTOs.PrintJobs; using Extrudex.Domain.Entities; using Extrudex.Domain.Enums; +using Extrudex.Domain.Interfaces; using Extrudex.Infrastructure.Data; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -19,16 +20,22 @@ namespace Extrudex.API.Controllers; public class PrintJobsController : ControllerBase { private readonly ExtrudexDbContext _dbContext; + private readonly ICostPerPrintService _costService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The database context for data access. + /// The cost-per-print calculation service. /// The logger for diagnostic output. - public PrintJobsController(ExtrudexDbContext dbContext, ILogger logger) + public PrintJobsController( + ExtrudexDbContext dbContext, + ICostPerPrintService costService, + ILogger logger) { _dbContext = dbContext; + _costService = costService; _logger = logger; } @@ -413,6 +420,34 @@ public class PrintJobsController : ControllerBase return NoContent(); } + // ── POST /api/printjobs/{id}/cost ───────────────────────────── + + /// + /// Calculates the cost of goods sold (COGS) for a specific print job. + /// Uses the spool’s purchase price and the print job’s derived grams consumed + /// to produce a cost breakdown. Returns warnings instead of errors when + /// cost data is missing or incomplete. + /// + /// The unique identifier of the print job. + /// A cost breakdown with warnings if data is incomplete. + /// Returns the cost breakdown for the print job. + /// If the print job with the given ID is not found. + [HttpPost("{id:guid}/cost")] + [ProducesResponseType(typeof(CostPerPrintResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CalculateCost(Guid id) + { + _logger.LogDebug("Calculating cost for print job {Id}", id); + + var result = await _costService.CalculateAsync(id); + + // If the job was not found, return 404 + if (result.Warnings.Any(w => w.Contains("not found"))) + return NotFound(new { error = $"Print job with ID '{id}' not found." }); + + return Ok(MapCostToResponse(result)); + } + // ── Gram Derivation Formula ──────────────────────────────────── /// @@ -509,4 +544,22 @@ public class PrintJobsController : ControllerBase CreatedAt = j.CreatedAt, UpdatedAt = j.UpdatedAt }; + + /// + /// Maps a domain CostPerPrintResult to an API CostPerPrintResponse DTO. + /// + private static CostPerPrintResponse MapCostToResponse(CostPerPrintResult r) => new() + { + PrintJobId = r.PrintJobId, + PrintName = r.PrintName, + SpoolId = r.SpoolId, + SpoolSerial = r.SpoolSerial, + MmExtruded = r.MmExtruded, + GramsDerived = r.GramsDerived, + PurchasePrice = r.PurchasePrice, + WeightTotalGrams = r.WeightTotalGrams, + CostPerGram = r.CostPerGram, + CostPerPrint = r.CostPerPrint, + Warnings = r.Warnings + }; } \ No newline at end of file diff --git a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs new file mode 100644 index 0000000..e82e6b3 --- /dev/null +++ b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs @@ -0,0 +1,99 @@ +using System.ComponentModel.DataAnnotations; + +namespace Extrudex.API.DTOs.PrintJobs; + +/// +/// Response DTO for cost-per-print calculation. Contains the full cost +/// breakdown and any warnings about missing or incomplete data. +/// +public class CostPerPrintResponse +{ + /// The print job identifier this result belongs to. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// The spool identifier that provided filament. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Total millimeters of filament extruded. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print. + public decimal GramsDerived { get; set; } + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if purchase price or total weight is missing. + public decimal? CostPerGram { get; set; } + + /// Calculated cost of this print job. Null if cost data is incomplete. + public decimal? CostPerPrint { get; set; } + + /// + /// Warnings about missing or incomplete data. Empty when all data is available + /// and the calculation succeeded. + /// + public List Warnings { get; set; } = new(); +} + +/// +/// Request DTO for batch cost calculation by spool. Returns cost breakdowns +/// for all print jobs associated with the specified spool. +/// +public class SpoolCostRequest +{ + /// The unique identifier of the spool to calculate costs for. + [Required(ErrorMessage = "SpoolId is required.")] + public Guid SpoolId { get; set; } +} + +/// +/// Response DTO for spool-level cost calculation. Contains cost breakdowns +/// for all print jobs on the spool, plus a total cost summary. +/// +public class SpoolCostResponse +{ + /// The spool identifier. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if cost data is incomplete. + public decimal? CostPerGram { get; set; } + + /// Total grams consumed across all print jobs on this spool. + public decimal TotalGramsConsumed { get; set; } + + /// Total calculated cost across all print jobs. Null if any job has missing data. + public decimal? TotalCost { get; set; } + + /// Number of print jobs included in this calculation. + public int JobCount { get; set; } + + /// + /// Individual cost breakdowns per print job. Jobs with missing data + /// will have null cost fields and populated warnings. + /// + public List Jobs { get; set; } = new(); + + /// + /// Aggregate warnings about missing data across all jobs. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/Domain/Interfaces/ICostPerPrintService.cs b/backend/Domain/Interfaces/ICostPerPrintService.cs new file mode 100644 index 0000000..041736a --- /dev/null +++ b/backend/Domain/Interfaces/ICostPerPrintService.cs @@ -0,0 +1,76 @@ +namespace Extrudex.Domain.Interfaces; + +/// +/// Service interface for calculating the cost of goods sold (COGS) per print job. +/// Uses the spool's purchase price and the print job's derived grams consumed +/// to produce a cost breakdown. Handles missing cost data gracefully by returning +/// warnings rather than throwing exceptions. +/// +public interface ICostPerPrintService +{ + /// + /// Calculates the cost per print for a specific print job. + /// + /// The unique identifier of the print job. + /// Optional cancellation token. + /// + /// A containing the cost breakdown, + /// or warnings if cost data is missing or incomplete. + /// + Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default); + + /// + /// Calculates cost breakdowns for all print jobs associated with a specific spool. + /// Useful for spool-level COGS reporting. + /// + /// The unique identifier of the spool. + /// Optional cancellation token. + /// + /// A list of for each print job on the spool. + /// Jobs with missing cost data will include warnings. + /// + Task> CalculateBySpoolAsync(Guid spoolId, CancellationToken cancellationToken = default); +} + +/// +/// Result of a cost-per-print calculation. Contains the cost breakdown +/// and any warnings about missing or incomplete cost data. +/// +public class CostPerPrintResult +{ + /// The print job identifier this result belongs to. + public Guid PrintJobId { get; set; } + + /// Human-readable name of the print job. + public string PrintName { get; set; } = string.Empty; + + /// The spool identifier that provided filament. + public Guid SpoolId { get; set; } + + /// Serial number of the spool. + public string SpoolSerial { get; set; } = string.Empty; + + /// Total millimeters of filament extruded. + public decimal MmExtruded { get; set; } + + /// Derived grams consumed for this print. + public decimal GramsDerived { get; set; } + + /// The spool's purchase price. Null if not recorded. + public decimal? PurchasePrice { get; set; } + + /// The spool's total weight in grams when full. + public decimal? WeightTotalGrams { get; set; } + + /// Cost per gram of filament. Null if purchase price or total weight is missing. + public decimal? CostPerGram { get; set; } + + /// Calculated cost of this print job. Null if cost data is incomplete. + public decimal? CostPerPrint { get; set; } + + /// + /// Warnings about missing or incomplete data that prevented a full calculation. + /// Empty when all data is available and the calculation succeeded. + /// + public List Warnings { get; set; } = new(); +} \ No newline at end of file diff --git a/backend/Infrastructure/Services/CostPerPrintService.cs b/backend/Infrastructure/Services/CostPerPrintService.cs new file mode 100644 index 0000000..e4b2593 --- /dev/null +++ b/backend/Infrastructure/Services/CostPerPrintService.cs @@ -0,0 +1,158 @@ +using Extrudex.Domain.Interfaces; +using Extrudex.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Extrudex.Infrastructure.Services; + +/// +/// Calculates the cost of goods sold (COGS) per print job using the spool's +/// purchase price and the print job's derived grams consumed. +/// +/// Formula: +/// cost_per_gram = purchase_price / weight_total_grams +/// cost_per_print = grams_derived × cost_per_gram +/// +/// Handles missing data gracefully — if the spool has no purchase price or +/// weight recorded, the result includes warnings and null cost fields +/// instead of throwing exceptions. +/// +public class CostPerPrintService : ICostPerPrintService +{ + private readonly ExtrudexDbContext _dbContext; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The database context for data access. + /// The logger for diagnostic output. + public CostPerPrintService(ExtrudexDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + public async Task CalculateAsync(Guid printJobId, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Calculating cost per print for job {PrintJobId}", printJobId); + + var job = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .FirstOrDefaultAsync(j => j.Id == printJobId, cancellationToken); + + if (job is null) + { + _logger.LogWarning("Print job {PrintJobId} not found for cost calculation", printJobId); + return new CostPerPrintResult + { + PrintJobId = printJobId, + Warnings = new List { $"Print job with ID '{printJobId}' not found." } + }; + } + + return BuildResult(job); + } + + /// + public async Task> CalculateBySpoolAsync( + Guid spoolId, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Calculating cost per print for all jobs on spool {SpoolId}", spoolId); + + var jobs = await _dbContext.PrintJobs + .Include(j => j.Spool) + .ThenInclude(s => s!.MaterialBase) + .Where(j => j.SpoolId == spoolId) + .OrderByDescending(j => j.CreatedAt) + .ToListAsync(cancellationToken); + + if (jobs.Count == 0) + { + _logger.LogDebug("No print jobs found for spool {SpoolId}", spoolId); + return Array.Empty(); + } + + return jobs.Select(BuildResult).ToList(); + } + + /// + /// Builds a from a print job entity. + /// Computes cost_per_gram and cost_per_print when all required data is available. + /// Populates warnings when data is missing or incomplete. + /// + /// The print job entity with Spool navigation loaded. + /// A cost calculation result with breakdown and any warnings. + private CostPerPrintResult BuildResult(Domain.Entities.PrintJob job) + { + var warnings = new List(); + var spool = job.Spool; + + // Map what we always have + var result = new CostPerPrintResult + { + PrintJobId = job.Id, + PrintName = job.PrintName, + SpoolId = job.SpoolId, + SpoolSerial = spool?.SpoolSerial ?? string.Empty, + MmExtruded = job.MmExtruded, + GramsDerived = job.GramsDerived, + }; + + // Guard: spool must be loaded + if (spool is null) + { + warnings.Add("Spool data is not available for this print job."); + result.Warnings = warnings; + return result; + } + + // Capture purchase price + result.PurchasePrice = spool.PurchasePrice; + result.WeightTotalGrams = spool.WeightTotalGrams; + + // Check for missing purchase price + if (!spool.PurchasePrice.HasValue) + { + warnings.Add( + "Spool purchase price is not recorded. Cost calculation requires a purchase price on the spool."); + } + + // Check for zero or negative weight — prevents division by zero + if (spool.WeightTotalGrams <= 0) + { + warnings.Add( + "Spool total weight is zero or not recorded. Cost calculation requires a positive weight_total_grams on the spool."); + } + + // Check for zero grams derived + if (job.GramsDerived <= 0) + { + warnings.Add( + "Derived grams consumed is zero. Ensure mm_extruded, filament diameter, and material density are recorded for this print job."); + } + + // If all data is present and valid, compute the cost + if (spool.PurchasePrice.HasValue && spool.WeightTotalGrams > 0 && job.GramsDerived > 0) + { + var costPerGram = spool.PurchasePrice.Value / spool.WeightTotalGrams; + result.CostPerGram = Math.Round(costPerGram, 6); + result.CostPerPrint = Math.Round(job.GramsDerived * costPerGram, 4); + + _logger.LogDebug( + "Cost calculated for job {PrintJobId}: {GramsDerived}g × {CostPerGram:C}/g = {CostPerPrint:C}", + job.Id, job.GramsDerived, result.CostPerGram, result.CostPerPrint); + } + else + { + _logger.LogDebug( + "Cost calculation incomplete for job {PrintJobId}: missing data (warnings: {WarningCount})", + job.Id, warnings.Count); + } + + result.Warnings = warnings; + return result; + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index adcb0d6..8a17991 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -50,6 +50,9 @@ builder.Services.AddSwaggerGen(c => // ── QR Code Generation ────────────────────────────────────── builder.Services.AddSingleton(); +// ── Cost Per Print Calculation ───────────────────────────── +builder.Services.AddScoped(); + // ── FluentValidation ────────────────────────────────────── // Registers all validators from the API assembly into DI. builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); From f5ca20307e1c884e5c8825d0654533de4fd477b3 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 18:12:58 +0000 Subject: [PATCH 3/5] CUB-36: add delete confirmation dialog for filament spool removal --- frontend/package-lock.json | 16 ++ frontend/package.json | 1 + frontend/src/app/app.config.ts | 8 +- .../delete-filament-dialog.component.html | 78 +++++++++ .../delete-filament-dialog.component.scss | 150 ++++++++++++++++++ .../delete-filament-dialog.component.ts | 68 ++++++++ .../filament-table.component.html | 26 ++- .../filament-table.component.scss | 14 ++ .../filament-table.component.ts | 72 ++++++++- frontend/src/app/services/filament.service.ts | 37 +++++ 10 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss create mode 100644 frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts create mode 100644 frontend/src/app/services/filament.service.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b31e340..d0614db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -326,6 +327,21 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "21.2.10", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.10.tgz", + "integrity": "sha512-sIzAcxwtRCJ/fu0tK4mo1ooiEaDxJ+Nl6s9nK1D1NP1em12VX03Jx8CMixp/kVtgh4mZnm1x6psBB0FUz3U3Ug==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.10" + } + }, "node_modules/@angular/build": { "version": "21.2.8", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e2cedb..02eaba4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "private": true, "packageManager": "npm@11.11.0", "dependencies": { + "@angular/animations": "^21.2.10", "@angular/cdk": "^21.2.8", "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..e66aad8 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,15 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) + provideRouter(routes), + provideHttpClient(withFetch()), + provideAnimationsAsync(), ] -}; +}; \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html new file mode 100644 index 0000000..71fe1c6 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html @@ -0,0 +1,78 @@ + +

+ + Delete Filament Spool? +

+ + +

+ You are about to permanently remove this filament spool from inventory. +

+ + +
+
+ Material + {{ filament.materialBaseName }}{{ filament.materialFinishName ? ' — ' + filament.materialFinishName : '' }}{{ filament.materialModifierName ? ' (' + filament.materialModifierName + ')' : '' }} +
+ +
+ Brand + {{ filament.brand }} +
+ +
+ Color + + + + {{ filament.colorName }} + +
+ +
+ Serial + {{ filament.spoolSerial }} +
+ +
+ Remaining + {{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }} +
+ +
+ Status + + + {{ filament.isActive ? 'Active' : 'Inactive' }} + + +
+
+ +

+ + This action cannot be undone. +

+
+ + + + + \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss new file mode 100644 index 0000000..6789bf4 --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss @@ -0,0 +1,150 @@ +/** + * Delete Filament Dialog Styles + * Touch-optimized confirmation dialog for spool removal + */ + +$spacing-unit: 8px; +$color-critical: #ef4444; +$color-inactive: #94a3b8; +$color-active: #22c55e; + +// Dialog title +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: $spacing-unit; + color: $color-critical; + + mat-icon { + font-size: 24px !important; + width: 24px !important; + height: 24px !important; + } +} + +// Description text +.dialog-description { + margin: 0 0 $spacing-unit * 2; + font-size: 14px; + line-height: 1.5; + color: var(--mat-sys-on-surface); +} + +// Spool details card +.spool-details { + display: flex; + flex-direction: column; + gap: $spacing-unit; + padding: $spacing-unit * 1.5; + background-color: var(--mat-sys-surface-container); + border-radius: 8px; + margin-bottom: $spacing-unit * 2; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-unit * 0.5 0; + font-size: 14px; + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-outline-variant); + padding-bottom: $spacing-unit; + } +} + +.detail-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; +} + +.detail-value { + font-weight: 400; + color: var(--mat-sys-on-surface); + text-align: right; +} + +// Color swatch inline +.color-swatch-inline { + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1.5px solid rgba(0, 0, 0, 0.12); + vertical-align: middle; + margin-right: 4px; +} + +.color-value { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +// Serial value — monospace +.serial-value { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 13px; + letter-spacing: 0.02em; +} + +// Status badge — matches filament table styling +.status-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + + &.active { + background-color: rgba($color-active, 0.12); + color: $color-active; + } + + &.inactive { + background-color: rgba($color-inactive, 0.12); + color: $color-inactive; + } +} + +// Warning text +.dialog-warning { + display: flex; + align-items: center; + gap: $spacing-unit; + margin: 0; + font-size: 13px; + color: $color-critical; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } +} + +// Dialog action buttons +mat-dialog-actions { + padding-top: $spacing-unit * 2; + + .cancel-button { + min-width: 80px; + } + + .confirm-button { + min-width: 120px; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + margin-right: 4px; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts new file mode 100644 index 0000000..d16a87a --- /dev/null +++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogModule, +} from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; + +import { Filament } from '../../models/filament.model'; + +/** + * Data passed into the delete confirmation dialog. + */ +export interface DeleteFilamentDialogData { + filament: Filament; +} + +/** + * Delete confirmation dialog for filament spool removal. + * + * Displays spool details (material, brand, color, serial, remaining weight) + * and requires the user to confirm before deletion proceeds. + * Cancel dismisses the dialog with no action. + */ +@Component({ + selector: 'app-delete-filament-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + ], + templateUrl: './delete-filament-dialog.component.html', + styleUrl: './delete-filament-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteFilamentDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data: DeleteFilamentDialogData = inject(MAT_DIALOG_DATA); + + /** The filament spool being considered for deletion */ + readonly filament = this.data.filament; + + /** Format weight for display in dialog */ + formatWeight(grams: number): string { + if (grams >= 1000) { + return `${(grams / 1000).toFixed(1)}kg`; + } + return `${Math.round(grams)}g`; + } + + /** Cancel — close dialog with false (no deletion) */ + onCancel(): void { + this.dialogRef.close(false); + } + + /** Confirm — close dialog with true (proceed with deletion) */ + onConfirm(): void { + this.dialogRef.close(true); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 75d798f..e0aa433 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,4 +1,4 @@ - +
@@ -106,10 +106,32 @@ + + + Actions + + + + + + [class.row-low]="classifyStockLevel(row) === 'low'" + [class.row-deleting]="deleting() === row.id"> diff --git a/frontend/src/app/components/filament-table/filament-table.component.scss b/frontend/src/app/components/filament-table/filament-table.component.scss index d85bb4a..c1a4f1a 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.scss +++ b/frontend/src/app/components/filament-table/filament-table.component.scss @@ -235,6 +235,20 @@ mat-chip { } } +// Actions column +.actions-cell { + display: flex; + align-items: center; + justify-content: center; +} + +// Row being deleted — subtle fade +:host ::ng-deep .row-deleting { + opacity: 0.5; + pointer-events: none; + transition: opacity 0.3s ease; +} + // Empty state .empty-state { display: flex; diff --git a/frontend/src/app/components/filament-table/filament-table.component.ts b/frontend/src/app/components/filament-table/filament-table.component.ts index 47025ac..9a0c590 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.ts +++ b/frontend/src/app/components/filament-table/filament-table.component.ts @@ -3,6 +3,7 @@ import { Component, Input, computed, + inject, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -12,12 +13,21 @@ import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSortModule, Sort } from '@angular/material/sort'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + import { Filament, StockLevel, getRemainingPercent, classifyStockLevel, } from '../../models/filament.model'; +import { FilamentService } from '../../services/filament.service'; +import { + DeleteFilamentDialogComponent, + DeleteFilamentDialogData, +} from '../delete-filament-dialog/delete-filament-dialog.component'; /** Display column definitions for the filament table */ export type FilamentColumn = @@ -27,7 +37,8 @@ export type FilamentColumn = | 'serial' | 'remaining' | 'stockLevel' - | 'status'; + | 'status' + | 'actions'; @Component({ selector: 'app-filament-table', @@ -40,16 +51,26 @@ export type FilamentColumn = MatProgressBarModule, MatTooltipModule, MatSortModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule, ], templateUrl: './filament-table.component.html', styleUrl: './filament-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilamentTableComponent { + private readonly dialog = inject(MatDialog); + private readonly snackBar = inject(MatSnackBar); + private readonly filamentService = inject(FilamentService); + /** Filament data input — reactive signal for live updates */ readonly filaments = signal([]); - /** Columns to display — defaults to all columns */ + /** Whether a delete operation is in progress */ + readonly deleting = signal(null); + + /** Columns to display — defaults to all columns including actions */ @Input() set displayedColumns(cols: FilamentColumn[]) { this._displayedColumns.set(cols); @@ -65,6 +86,7 @@ export class FilamentTableComponent { 'remaining', 'stockLevel', 'status', + 'actions', ]); /** Default columns for template binding */ @@ -252,6 +274,52 @@ export class FilamentTableComponent { this.sortedFilaments.set(sorted); } + /** + * Open the delete confirmation dialog for a filament spool. + * On confirm: calls DELETE endpoint and removes the row on success. + * On cancel: dialog dismissed, no action taken. + */ + onDeleteClick(filament: Filament): void { + const dialogData: DeleteFilamentDialogData = { filament }; + const dialogRef = this.dialog.open(DeleteFilamentDialogComponent, { + data: dialogData, + width: '480px', + disableClose: true, + }); + + dialogRef.afterClosed().subscribe((confirmed: boolean | undefined) => { + if (!confirmed) { + return; // User cancelled — no action + } + + // Mark as deleting for UI feedback + this.deleting.set(filament.id); + + this.filamentService.deleteFilament(filament.id).subscribe({ + next: () => { + // Remove the deleted filament from local data + const updated = this.filaments().filter((f) => f.id !== filament.id); + this.updateFilaments(updated); + this.deleting.set(null); + + this.snackBar.open( + `Deleted ${filament.materialBaseName} — ${filament.colorName}`, + 'Dismiss', + { duration: 4000 } + ); + }, + error: () => { + this.deleting.set(null); + this.snackBar.open( + `Failed to delete ${filament.materialBaseName} — ${filament.colorName}. Please try again.`, + 'Dismiss', + { duration: 6000 } + ); + }, + }); + }); + } + /** Template helper: get remaining percent */ getRemainingPercent = getRemainingPercent; diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts new file mode 100644 index 0000000..01c4755 --- /dev/null +++ b/frontend/src/app/services/filament.service.ts @@ -0,0 +1,37 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { Filament } from '../models/filament.model'; + +/** + * API base URL — matches the Extrudex backend default. + * TODO: Move to environment config when multi-environment support is added. + */ +const API_BASE_URL = '/api'; + +/** + * Service for CRUD operations on filament spools. + * Communicates with the Extrudex backend SpoolsController. + */ +@Injectable({ providedIn: 'root' }) +export class FilamentService { + private readonly http = inject(HttpClient); + + /** + * Fetch all filament spools from the backend. + * GET /api/spools + */ + getFilaments(): Observable { + return this.http.get(`${API_BASE_URL}/spools`); + } + + /** + * Soft-delete a filament spool by ID. + * DELETE /api/spools/{id} + * Returns 204 No Content on success. + */ + deleteFilament(id: string): Observable { + return this.http.delete(`${API_BASE_URL}/spools/${id}`); + } +} \ No newline at end of file From d207c49ffd8149fea2bab7a4ae61d700bac211a6 Mon Sep 17 00:00:00 2001 From: dex-bot Date: Mon, 27 Apr 2026 15:08:31 -0400 Subject: [PATCH 4/5] CUB-34: add filament filter bar with material type, color, and low stock filters --- .../filament-filter.component.html | 76 +++++++++ .../filament-filter.component.scss | 134 +++++++++++++++ .../filament-filter.component.ts | 158 ++++++++++++++++++ .../filament-table.component.html | 20 ++- .../filament-table.component.ts | 60 +++++++ 5 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.html create mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.scss create mode 100644 frontend/src/app/components/filament-filter/filament-filter.component.ts diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.html b/frontend/src/app/components/filament-filter/filament-filter.component.html new file mode 100644 index 0000000..fd93087 --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.html @@ -0,0 +1,76 @@ + + \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.scss b/frontend/src/app/components/filament-filter/filament-filter.component.scss new file mode 100644 index 0000000..8ebbaaf --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.scss @@ -0,0 +1,134 @@ +/** + * Filament Filter Bar Styles + * Responsive filter layout for kiosk and mobile + */ + +$spacing-unit: 8px; + +.filament-filter-bar { + display: flex; + align-items: center; + gap: $spacing-unit * 2; + flex-wrap: wrap; + padding: $spacing-unit * 2 0; + margin-bottom: $spacing-unit * 2; +} + +// Form field sizing +.filter-field { + flex: 0 1 auto; + min-width: 160px; + + &.material-filter { + min-width: 200px; + } + + &.color-filter { + min-width: 180px; + } + + // Reduce vertical spacing inside filter fields + .mat-mdc-form-field-subscript-wrapper { + display: none; // No hint/error text needed for filters + } +} + +// Selected material chips +.selected-chips { + flex-wrap: wrap; + gap: 4px; +} + +.filter-chip { + font-size: 12px !important; + min-height: 24px !important; + + mat-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + } +} + +// Active filter icon +.filter-active-icon { + color: var(--mat-sys-primary); + font-size: 18px !important; + width: 18px !important; + height: 18px !important; +} + +// Checkbox styling +.filter-checkbox { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + user-select: none; + touch-action: manipulation; // Prevent zoom on double-tap + + .checkbox-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: var(--mat-sys-on-surface-variant); + transition: color 0.2s ease; + + &.active { + color: var(--mat-sys-primary); + } + } +} + +// Clear filters button +.clear-filters-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + } +} + +// Responsive: stack filters vertically on small screens +@media (max-width: 768px) { + .filament-filter-bar { + flex-direction: column; + align-items: stretch; + gap: $spacing-unit; + } + + .filter-field { + width: 100%; + min-width: unset; + + &.material-filter, + &.color-filter { + min-width: unset; + } + } + + .filter-checkbox { + padding: $spacing-unit 0; + } + + .clear-filters-btn { + align-self: flex-start; + } +} + +// Extra-small screens (phone portrait) +@media (max-width: 480px) { + .filament-filter-bar { + padding: $spacing-unit 0; + margin-bottom: $spacing-unit; + } + + .filter-checkbox { + font-size: 13px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.ts b/frontend/src/app/components/filament-filter/filament-filter.component.ts new file mode 100644 index 0000000..7559afc --- /dev/null +++ b/frontend/src/app/components/filament-filter/filament-filter.component.ts @@ -0,0 +1,158 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + computed, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + Filament, + StockLevel, + classifyStockLevel, +} from '../../models/filament.model'; + +/** Filter state emitted by the filament filter component */ +export interface FilamentFilterState { + /** Selected material base names — empty means all */ + materialBaseNames: string[]; + + /** Color search text — empty string means all */ + colorSearch: string; + + /** Whether to show only low/critical stock */ + lowStockOnly: boolean; + + /** Whether to show only active spools */ + activeOnly: boolean; +} + +/** + * FilamentFilterComponent — Filter bar for the filament inventory list. + * + * Provides: + * - Material type multi-select filter + * - Color name text search + * - Low stock toggle (shows only critical/low spools) + * - Active-only toggle + * - Clear all filters action + */ +@Component({ + selector: 'app-filament-filter', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatCheckboxModule, + MatIconModule, + MatChipsModule, + MatButtonModule, + MatTooltipModule, + ], + templateUrl: './filament-filter.component.html', + styleUrl: './filament-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilamentFilterComponent { + /** Filament data input — used to derive material options */ + @Input() set filaments(value: Filament[]) { + this._filaments.set(value); + const materials = [...new Set(value.map((f) => f.materialBaseName))].sort(); + this.materialOptions.set(materials); + } + get filaments(): Filament[] { + return this._filaments(); + } + private readonly _filaments = signal([]); + + /** Available material base names derived from filament data */ + readonly materialOptions = signal([]); + + /** Selected material base names */ + readonly selectedMaterials = signal([]); + + /** Color search text */ + readonly colorSearch = signal(''); + + /** Low stock only toggle */ + readonly lowStockOnly = signal(false); + + /** Active only toggle */ + readonly activeOnly = signal(false); + + /** Computed: whether any filters are active */ + readonly hasActiveFilters = computed( + () => + this.selectedMaterials().length > 0 || + this.colorSearch().trim().length > 0 || + this.lowStockOnly() || + this.activeOnly() + ); + + /** Emits the current filter state whenever filters change */ + @Output() readonly filterChange = new EventEmitter(); + + /** Handle material selection change */ + onMaterialChange(selected: string[]): void { + this.selectedMaterials.set(selected); + this.emitFilterState(); + } + + /** Handle color search input */ + onColorSearchChange(value: string): void { + this.colorSearch.set(value); + this.emitFilterState(); + } + + /** Handle low stock toggle */ + onLowStockToggle(checked: boolean): void { + this.lowStockOnly.set(checked); + this.emitFilterState(); + } + + /** Handle active-only toggle */ + onActiveOnlyToggle(checked: boolean): void { + this.activeOnly.set(checked); + this.emitFilterState(); + } + + /** Remove a single material chip */ + removeMaterial(material: string): void { + const updated = this.selectedMaterials().filter((m) => m !== material); + this.selectedMaterials.set(updated); + this.emitFilterState(); + } + + /** Clear all filters */ + clearAll(): void { + this.selectedMaterials.set([]); + this.colorSearch.set(''); + this.lowStockOnly.set(false); + this.activeOnly.set(false); + this.emitFilterState(); + } + + /** Emit the current filter state */ + private emitFilterState(): void { + this.filterChange.emit({ + materialBaseNames: this.selectedMaterials(), + colorSearch: this.colorSearch().trim().toLowerCase(), + lowStockOnly: this.lowStockOnly(), + activeOnly: this.activeOnly(), + }); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html index 75d798f..5beccd4 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,6 +1,12 @@ - +
+ + + @if (criticalCount() > 0) {