diff --git a/backend/API/Controllers/CostAnalysisController.cs b/backend/API/Controllers/CostAnalysisController.cs deleted file mode 100644 index 87e94b0..0000000 --- a/backend/API/Controllers/CostAnalysisController.cs +++ /dev/null @@ -1,108 +0,0 @@ -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/DTOs/PrintJobs/CostPerPrintDtos.cs b/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs deleted file mode 100644 index e82e6b3..0000000 --- a/backend/API/DTOs/PrintJobs/CostPerPrintDtos.cs +++ /dev/null @@ -1,99 +0,0 @@ -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/API/Jobs/FilamentUsageSyncJob.cs b/backend/API/Jobs/FilamentUsageSyncJob.cs deleted file mode 100644 index 19c991d..0000000 --- a/backend/API/Jobs/FilamentUsageSyncJob.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Extrudex.API.Jobs; - -/// -/// Background job that periodically syncs filament usage data from -/// Moonraker printers. Runs as a hosted service and polls all active -/// Moonraker printers on a configurable interval to persist usage -/// data to the Extrudex database. -/// -/// Configuration is bound from the "FilamentUsageSync" section in -/// appsettings.json. Set Enabled=false to disable without removing -/// the service registration. -/// -public class FilamentUsageSyncJob : BackgroundService -{ - private readonly IFilamentUsageSyncService _syncService; - private readonly FilamentUsageSyncOptions _options; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncJob. - /// - /// The service that performs the actual sync logic. - /// Configuration options for polling interval and timeouts. - /// Logger for diagnostic output. - public FilamentUsageSyncJob( - IFilamentUsageSyncService syncService, - IOptions options, - ILogger logger) - { - _syncService = syncService; - _options = options.Value; - _logger = logger; - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_options.Enabled) - { - _logger.LogInformation("Filament usage sync job is disabled via configuration — exiting"); - return; - } - - _logger.LogInformation( - "Filament usage sync job starting — polling every {Interval}", - _options.PollingInterval); - - // Delay briefly on startup to allow the web host to fully initialize - await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - var syncedCount = await _syncService.SyncAllAsync(stoppingToken); - - _logger.LogInformation( - "Filament usage sync completed — {SyncedCount} printer(s) synced. Next sync in {Interval}", - syncedCount, _options.PollingInterval); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, - "Error during filament usage sync cycle — will retry in {Interval}", - _options.PollingInterval); - } - - await Task.Delay(_options.PollingInterval, stoppingToken); - } - - _logger.LogInformation("Filament usage sync job shutting down"); - } -} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 23aacef..b604978 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,9 +17,6 @@ RUN dotnet publish Extrudex.csproj \ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime WORKDIR /app -# Install curl for health check (not included in aspnet base image) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - # Non-root user for security RUN adduser --disabled-password --gecos "" appuser USER appuser diff --git a/backend/Domain/Interfaces/ICostPerPrintService.cs b/backend/Domain/Interfaces/ICostPerPrintService.cs deleted file mode 100644 index 041736a..0000000 --- a/backend/Domain/Interfaces/ICostPerPrintService.cs +++ /dev/null @@ -1,76 +0,0 @@ -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/Domain/Interfaces/IFilamentUsageSyncService.cs b/backend/Domain/Interfaces/IFilamentUsageSyncService.cs deleted file mode 100644 index 951f80d..0000000 --- a/backend/Domain/Interfaces/IFilamentUsageSyncService.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Service interface for syncing filament usage data from printers -/// into the Extrudex database. Handles querying Moonraker printers, -/// computing derived usage metrics, and persisting updates to spools -/// and print job records. -/// -public interface IFilamentUsageSyncService -{ - /// - /// Performs a single sync cycle: queries all active Moonraker printers, - /// fetches their current filament usage data, and persists updates to - /// the database. - /// - /// Cancellation token for graceful shutdown. - /// The number of printers successfully synced. - Task SyncAllAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/backend/Domain/Interfaces/IMoonrakerClient.cs b/backend/Domain/Interfaces/IMoonrakerClient.cs deleted file mode 100644 index d4599f2..0000000 --- a/backend/Domain/Interfaces/IMoonrakerClient.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Extrudex.Domain.Interfaces; - -/// -/// Client interface for communicating with Moonraker REST API endpoints -/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). -/// Used to retrieve filament usage data, print job status, and -/// remaining spool weight from the printer. -/// -public interface IMoonrakerClient -{ - /// - /// Fetches the current filament usage data from the Moonraker server. - /// Returns a dictionary of usage metrics reported by the printer. - /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). - /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// A dictionary of usage metric names to their decimal values. - Task> GetFilamentUsageAsync( - string hostnameOrIp, - int port, - string? apiKey, - CancellationToken cancellationToken = default); - - /// - /// Checks whether the Moonraker server is reachable and responding. - /// - /// The printer's hostname or IP address. - /// The Moonraker API port (default: 7125). - /// Optional API key for authentication. - /// Cancellation token for the HTTP request. - /// true if the server responded successfully; otherwise false. - Task IsReachableAsync( - string hostnameOrIp, - int port, - string? apiKey, - CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs b/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs deleted file mode 100644 index 29f95b2..0000000 --- a/backend/Infrastructure/Configuration/FilamentUsageSyncOptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Extrudex.Infrastructure.Configuration; - -/// -/// Configuration options for the FilamentUsageSync background job. -/// Bound from appsettings.json under the "FilamentUsageSync" section. -/// Controls polling interval and per-printer timeout settings. -/// -public class FilamentUsageSyncOptions -{ - /// - /// The section name in appsettings.json where these options are bound. - /// - public const string SectionName = "FilamentUsageSync"; - - /// - /// How often the background job polls printers for usage data. - /// Default: 5 minutes. Minimum recommended: 1 minute. - /// - public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(5); - - /// - /// Timeout for individual HTTP requests to a Moonraker printer. - /// Default: 30 seconds. - /// - public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Whether the sync job is enabled. Set to false to disable - /// the background job without removing its registration. - /// Default: true. - /// - public bool Enabled { get; set; } = true; -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/CostPerPrintService.cs b/backend/Infrastructure/Services/CostPerPrintService.cs deleted file mode 100644 index e4b2593..0000000 --- a/backend/Infrastructure/Services/CostPerPrintService.cs +++ /dev/null @@ -1,158 +0,0 @@ -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/Infrastructure/Services/FilamentUsageSyncService.cs b/backend/Infrastructure/Services/FilamentUsageSyncService.cs deleted file mode 100644 index c2e305b..0000000 --- a/backend/Infrastructure/Services/FilamentUsageSyncService.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Extrudex.Domain.Enums; -using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Extrudex.Infrastructure.Configuration; - -/// -/// Service that syncs filament usage data from Moonraker printers into the -/// Extrudex database. Queries all active Moonraker printers, fetches their -/// current filament usage metrics, and updates spool remaining weights and -/// print job records. -/// -public class FilamentUsageSyncService : IFilamentUsageSyncService -{ - private readonly ExtrudexDbContext _dbContext; - private readonly IMoonrakerClient _moonrakerClient; - private readonly ILogger _logger; - - /// - /// Creates a new FilamentUsageSyncService. - /// - /// The EF Core database context for persisting updates. - /// The Moonraker HTTP client for fetching printer data. - /// Logger for diagnostic output. - public FilamentUsageSyncService( - ExtrudexDbContext dbContext, - IMoonrakerClient moonrakerClient, - ILogger logger) - { - _dbContext = dbContext; - _moonrakerClient = moonrakerClient; - _logger = logger; - } - - /// - public async Task SyncAllAsync(CancellationToken cancellationToken = default) - { - _logger.LogInformation("Starting filament usage sync cycle"); - - var printers = await _dbContext.Printers - .Where(p => p.IsActive && p.ConnectionType == ConnectionType.Moonraker) - .Include(p => p.AmsUnits) - .ThenInclude(u => u.Slots) - .ThenInclude(s => s.Spool) - .ToListAsync(cancellationToken); - - if (printers.Count == 0) - { - _logger.LogInformation("No active Moonraker printers found — skipping sync"); - return 0; - } - - _logger.LogInformation("Found {PrinterCount} active Moonraker printer(s) to sync", printers.Count); - - var syncedCount = 0; - - foreach (var printer in printers) - { - try - { - var usageData = await _moonrakerClient.GetFilamentUsageAsync( - printer.HostnameOrIp, - printer.Port, - printer.ApiKey, - cancellationToken); - - if (usageData.Count == 0) - { - _logger.LogWarning( - "No usage data returned from printer {PrinterName} ({Host}:{Port})", - printer.Name, printer.HostnameOrIp, printer.Port); - continue; - } - - // Update spool remaining weights from AMS data - UpdateSpoolWeights(printer, usageData); - - // Mark printer as seen and idle (reachable = idle, not printing) - printer.LastSeenAt = DateTime.UtcNow; - printer.Status = PrinterStatus.Idle; - - syncedCount++; - _logger.LogInformation( - "Successfully synced filament usage from printer {PrinterName}", - printer.Name); - } - catch (Exception ex) - { - _logger.LogError(ex, - "Error syncing filament usage from printer {PrinterName} ({Host}:{Port})", - printer.Name, printer.HostnameOrIp, printer.Port); - } - } - - await _dbContext.SaveChangesAsync(cancellationToken); - - _logger.LogInformation( - "Filament usage sync cycle complete — {SyncedCount}/{TotalCount} printers synced", - syncedCount, printers.Count); - - return syncedCount; - } - - /// - /// Updates spool remaining weights based on usage data received from Moonraker. - /// For printers with AMS units, updates the remaining weight on each slot's spool. - /// - private void UpdateSpoolWeights( - Domain.Entities.Printer printer, - Dictionary usageData) - { - // Update AMS slot remaining weights if available - foreach (var amsUnit in printer.AmsUnits) - { - foreach (var slot in amsUnit.Slots) - { - if (slot.Spool != null && slot.RemainingWeightG.HasValue) - { - // Sync the AMS-reported remaining weight to the spool - slot.Spool.WeightRemainingGrams = slot.RemainingWeightG.Value; - - _logger.LogDebug( - "Updated spool {SpoolSerial} remaining weight to {Weight}g", - slot.Spool.SpoolSerial, slot.RemainingWeightG.Value); - } - } - } - - // If usage data contains extruded mm, log it for observability - if (usageData.TryGetValue("mm_extruded", out var mmExtruded) && mmExtruded > 0) - { - _logger.LogInformation( - "Printer {PrinterName} reports {MmExtruded}mm filament extruded in latest job", - printer.Name, mmExtruded); - } - } -} \ No newline at end of file diff --git a/backend/Infrastructure/Services/MoonrakerClient.cs b/backend/Infrastructure/Services/MoonrakerClient.cs deleted file mode 100644 index 1666dcf..0000000 --- a/backend/Infrastructure/Services/MoonrakerClient.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using Extrudex.Domain.Interfaces; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Extrudex.Infrastructure.Configuration; - -/// -/// HTTP client for communicating with Moonraker REST API endpoints -/// on Klipper-based printers (e.g., Elegoo Centauri Carbon). -/// Retrieves filament usage data and printer status information. -/// -public class MoonrakerClient : IMoonrakerClient -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - /// - /// Creates a new MoonrakerClient with the configured HTTP client and logger. - /// - /// The HTTP client for making requests to Moonraker endpoints. - /// Logger for diagnostic output. - public MoonrakerClient(HttpClient httpClient, ILogger logger) - { - _httpClient = httpClient; - _logger = logger; - } - - /// - public async Task> GetFilamentUsageAsync( - string hostnameOrIp, - int port, - string? apiKey, - CancellationToken cancellationToken = default) - { - var baseUrl = BuildBaseUrl(hostnameOrIp, port); - var result = new Dictionary(); - - try - { - // Query Moonraker server info endpoint for filament usage data - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/history/items?limit=1"); - if (!string.IsNullOrEmpty(apiKey)) - { - request.Headers.Add("X-Api-Key", apiKey); - } - - using var response = await _httpClient.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - - // Extract filament usage from the response - // Moonraker returns job history with filament_used and similar fields - if (json.TryGetProperty("result", out var resultElement)) - { - if (resultElement.TryGetProperty("items", out var items) && items.GetArrayLength() > 0) - { - var job = items[0]; - - // Moonraker tracks filament_used in millimeters - if (job.TryGetProperty("filament_used", out var filamentUsed)) - { - result["mm_extruded"] = filamentUsed.GetDecimal(); - } - - // Total duration in seconds - if (job.TryGetProperty("print_duration", out var duration)) - { - result["print_duration_seconds"] = duration.GetDecimal(); - } - } - } - - _logger.LogDebug( - "Retrieved filament usage from Moonraker at {Host}:{Port}: {MetricCount} metrics", - hostnameOrIp, port, result.Count); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, - "Failed to retrieve filament usage from Moonraker at {Host}:{Port}", - hostnameOrIp, port); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, - "Failed to parse Moonraker response from {Host}:{Port}", - hostnameOrIp, port); - } - - return result; - } - - /// - public async Task IsReachableAsync( - string hostnameOrIp, - int port, - string? apiKey, - CancellationToken cancellationToken = default) - { - var baseUrl = BuildBaseUrl(hostnameOrIp, port); - - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/server/info"); - if (!string.IsNullOrEmpty(apiKey)) - { - request.Headers.Add("X-Api-Key", apiKey); - } - - using var response = await _httpClient.SendAsync(request, cancellationToken); - return response.IsSuccessStatusCode; - } - catch (HttpRequestException) - { - _logger.LogDebug("Moonraker at {Host}:{Port} is not reachable", hostnameOrIp, port); - return false; - } - } - - /// - /// Builds the base URL for Moonraker API calls from hostname and port. - /// - private static string BuildBaseUrl(string hostnameOrIp, int port) - { - return $"http://{hostnameOrIp}:{port}"; - } -} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index c2bdcc9..adcb0d6 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,9 +1,7 @@ using System.Reflection; using Extrudex.API.Filters; using Extrudex.API.Hubs; -using Extrudex.API.Jobs; using Extrudex.Domain.Interfaces; -using Extrudex.Infrastructure.Configuration; using Extrudex.Infrastructure.Data; using Extrudex.Infrastructure.Services; using FluentValidation; @@ -52,9 +50,6 @@ 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()); @@ -82,16 +77,6 @@ builder.Services.AddCors(options => // ── SignalR (real-time printer updates) ──────────────────── builder.Services.AddSignalR(); -// ── Filament Usage Sync (Background Job) ────────────────── -builder.Services.Configure( - builder.Configuration.GetSection(FilamentUsageSyncOptions.SectionName)); -builder.Services.AddHttpClient(client => -{ - client.DefaultRequestHeaders.Add("User-Agent", "Extrudex/1.0"); -}); -builder.Services.AddScoped(); -builder.Services.AddHostedService(); - // ── Health Checks ─────────────────────────────────────────── builder.Services.AddHealthChecks() .AddNpgSql(connectionString); diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index 06130e3..8edfdd9 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -8,10 +8,5 @@ }, "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex_dev;Username=extrudex;Password=changeme" - }, - "FilamentUsageSync": { - "PollingInterval": "00:01:00", - "RequestTimeout": "00:00:30", - "Enabled": true } } \ No newline at end of file diff --git a/backend/appsettings.json b/backend/appsettings.json index e5c747f..d924e27 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -9,10 +9,5 @@ "AllowedHosts": "*", "ConnectionStrings": { "ExtrudexDb": "Host=localhost;Port=5432;Database=extrudex;Username=extrudex;Password=changeme" - }, - "FilamentUsageSync": { - "PollingInterval": "00:05:00", - "RequestTimeout": "00:00:30", - "Enabled": true } } \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index d00c1e6..d17960c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,14 +18,13 @@ echo "📦 Building and starting services..." $COMPOSE_CMD -f docker-compose.dev.yml up -d --build echo "⏳ Waiting for services to become healthy..." -sleep 15 +sleep 10 echo "✅ Deployment complete!" echo "" echo "Services running:" -echo " • PostgreSQL: localhost:5433" echo " • Extrudex API: http://localhost:5080" -echo " • Extrudex Web: http://localhost:5081" +echo " • Control Center Web: http://localhost:5081" echo "" echo "To view logs:" echo " $COMPOSE_CMD -f docker-compose.dev.yml logs -f" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0a3d49..2859dff 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,25 +1,6 @@ -services: - extrudex-db: - image: postgres:16-alpine - container_name: extrudex-db - environment: - POSTGRES_USER: extrudex - POSTGRES_PASSWORD: changeme - POSTGRES_DB: extrudex - ports: - - "5433:5432" - volumes: - - extrudex-db-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U extrudex"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - restart: unless-stopped - networks: - - extrudex-network +version: '3.8' +services: extrudex-api: build: context: ./backend @@ -30,14 +11,6 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 - - EXTRUDEX_DB_HOST=extrudex-db - - EXTRUDEX_DB_PORT=5432 - - EXTRUDEX_DB_NAME=extrudex - - EXTRUDEX_DB_USER=extrudex - - EXTRUDEX_DB_PASSWORD=changeme - depends_on: - extrudex-db: - condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] @@ -48,11 +21,11 @@ services: networks: - extrudex-network - extrudex-web: + control-center-web: build: - context: ./frontend + context: ../Control-Center/frontend dockerfile: Dockerfile - container_name: extrudex-web + container_name: control-center-web ports: - "5081:80" depends_on: @@ -62,9 +35,6 @@ services: networks: - extrudex-network -volumes: - extrudex-db-data: - networks: extrudex-network: driver: bridge \ No newline at end of file diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..b1fd09d 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 } 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(), + provideAnimationsAsync(), ] -}; +}; \ No newline at end of file diff --git a/frontend/src/app/components/filament-filter/filament-filter.component.html b/frontend/src/app/components/filament-filter/filament-filter.component.html deleted file mode 100644 index fd93087..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.html +++ /dev/null @@ -1,76 +0,0 @@ - - \ 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 deleted file mode 100644 index 8ebbaaf..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.scss +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7559afc..0000000 --- a/frontend/src/app/components/filament-filter/filament-filter.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 5beccd4..b7929f2 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.html +++ b/frontend/src/app/components/filament-table/filament-table.component.html @@ -1,11 +1,15 @@ - +
- - + +
+

Filament Inventory

+ +
@if (criticalCount() > 0) { @@ -22,7 +26,7 @@ + + + + + +
+ +
- - @if (filteredFilaments().length === 0 && filaments().length > 0) { -
- -

No filaments match the current filters

-
- } - - + @if (filaments().length === 0) {
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 f20b831..a9f427c 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,13 +13,18 @@ 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 { FilamentFilterComponent, FilamentFilterState } from '../filament-filter/filament-filter.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { Filament, StockLevel, getRemainingPercent, classifyStockLevel, } from '../../models/filament.model'; +import { + FilamentDialogComponent, + FilamentDialogData, +} from '../filament-dialog/filament-dialog.component'; /** Display column definitions for the filament table */ export type FilamentColumn = @@ -28,7 +34,8 @@ export type FilamentColumn = | 'serial' | 'remaining' | 'stockLevel' - | 'status'; + | 'status' + | 'actions'; @Component({ selector: 'app-filament-table', @@ -41,13 +48,16 @@ export type FilamentColumn = MatProgressBarModule, MatTooltipModule, MatSortModule, - FilamentFilterComponent, + MatButtonModule, + MatDialogModule, ], templateUrl: './filament-table.component.html', styleUrl: './filament-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilamentTableComponent { + private readonly dialog = inject(MatDialog); + /** Filament data input — reactive signal for live updates */ readonly filaments = signal([]); @@ -67,29 +77,15 @@ export class FilamentTableComponent { 'remaining', 'stockLevel', 'status', + 'actions', ]); /** Default columns for template binding */ readonly columns = this._displayedColumns; - /** Current filter state */ - readonly filterState = signal({ - materialBaseNames: [], - colorSearch: '', - lowStockOnly: false, - activeOnly: false, - }); - /** Sorted filament data */ readonly sortedFilaments = signal([]); - /** Computed: filtered + sorted filament data for display */ - readonly filteredFilaments = computed(() => { - const data = this.sortedFilaments(); - const filters = this.filterState(); - return data.filter((f) => this.matchesFilter(f, filters)); - }); - /** Computed: count of low/critical spools */ readonly lowStockCount = computed(() => this.filaments().filter( @@ -228,9 +224,6 @@ export class FilamentTableComponent { this.sortedFilaments.set([...data]); } - /** All filament data — for the filter component to derive material options */ - readonly allFilaments = this.filaments; - /** Handle sort changes from MatSort */ sortData(sort: Sort): void { const data = [...this.filaments()]; @@ -272,46 +265,6 @@ export class FilamentTableComponent { this.sortedFilaments.set(sorted); } - /** Handle filter changes from FilamentFilterComponent */ - onFilterChange(state: FilamentFilterState): void { - this.filterState.set(state); - } - - /** Check if a filament matches the current filter state */ - private matchesFilter(filament: Filament, filters: FilamentFilterState): boolean { - // Material filter — empty means all - if ( - filters.materialBaseNames.length > 0 && - !filters.materialBaseNames.includes(filament.materialBaseName) - ) { - return false; - } - - // Color search — empty means all - if ( - filters.colorSearch && - !filament.colorName.toLowerCase().includes(filters.colorSearch) && - !filament.colorHex.toLowerCase().includes(filters.colorSearch) - ) { - return false; - } - - // Low stock filter — show only critical/low - if (filters.lowStockOnly) { - const level = classifyStockLevel(filament); - if (level !== 'critical' && level !== 'low') { - return false; - } - } - - // Active only filter - if (filters.activeOnly && !filament.isActive) { - return false; - } - - return true; - } - /** Template helper: get remaining percent */ getRemainingPercent = getRemainingPercent; @@ -353,6 +306,47 @@ export class FilamentTableComponent { } return `${Math.round(grams)}g`; } + + /** Open the add filament dialog. */ + openAddDialog(): void { + const data: FilamentDialogData = {}; + const ref = this.dialog.open(FilamentDialogComponent, { + width: '600px', + maxWidth: '95vw', + data, + autoFocus: 'first-typable', + }); + + ref.afterClosed().subscribe((result: boolean) => { + if (result) { + this.onFilamentSaved(); + } + }); + } + + /** Open the edit filament dialog for a specific spool. */ + openEditDialog(filament: Filament): void { + const data: FilamentDialogData = { filament }; + const ref = this.dialog.open(FilamentDialogComponent, { + width: '600px', + maxWidth: '95vw', + data, + autoFocus: 'first-typable', + }); + + ref.afterClosed().subscribe((result: boolean) => { + if (result) { + this.onFilamentSaved(); + } + }); + } + + /** Called after a successful save — reload filament data. */ + protected onFilamentSaved(): void { + // TODO: Replace with FilamentService.refresh() call when SignalR integration is ready. + // For now, this is the hook for refreshing data after a save. + // Consumers can override or listen to signal changes. + } } /** Compare helper for sorting */