import { ChangeDetectionStrategy, Component, Input, OnInit, computed, inject, signal, } from '@angular/core'; import { FilamentService } from '../../services/filament.service'; import { CommonModule } from '@angular/common'; import { MatTableModule } from '@angular/material/table'; import { MatChipsModule } from '@angular/material/chips'; 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 { Filament, StockLevel, getRemainingPercent, classifyStockLevel, } from '../../models/filament.model'; /** Display column definitions for the filament table */ export type FilamentColumn = | 'color' | 'material' | 'brand' | 'serial' | 'remaining' | 'cost' | 'usage' | 'stockLevel' | 'status'; @Component({ selector: 'app-filament-table', standalone: true, imports: [ CommonModule, MatTableModule, MatChipsModule, MatIconModule, MatProgressBarModule, MatTooltipModule, MatSortModule, FilamentFilterComponent, ], templateUrl: './filament-table.component.html', styleUrl: './filament-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilamentTableComponent implements OnInit { private readonly filamentService = inject(FilamentService); /** Filament data — reactive signal driven by FilamentService */ readonly filaments = this.filamentService.filaments; /** Columns to display — defaults to all columns */ @Input() set displayedColumns(cols: FilamentColumn[]) { this._displayedColumns.set(cols); } get displayedColumns(): FilamentColumn[] { return this._displayedColumns(); } private readonly _displayedColumns = signal([ 'color', 'material', 'brand', 'serial', 'remaining', 'cost', 'usage', 'stockLevel', 'status', ]); /** 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( (f) => classifyStockLevel(f) === 'low' || classifyStockLevel(f) === 'critical' ).length ); /** Computed: count of critical spools */ readonly criticalCount = computed(() => this.filaments().filter((f) => classifyStockLevel(f) === 'critical').length ); ngOnInit(): void { // Initialize sorted data from FilamentService this.sortedFilaments.set([...this.filaments()]); } /** Update filament data — called externally or from a SignalR handler */ updateFilaments(data: Filament[]): void { this.filamentService.setFilaments(data); 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()]; if (!sort.active || sort.direction === '') { this.sortedFilaments.set(data); return; } const sorted = data.sort((a, b) => { const isAsc = sort.direction === 'asc'; switch (sort.active as FilamentColumn) { case 'material': return compare(a.materialBaseName, b.materialBaseName, isAsc); case 'brand': return compare(a.brand, b.brand, isAsc); case 'serial': return compare(a.spoolSerial, b.spoolSerial, isAsc); case 'remaining': return compare( getRemainingPercent(a), getRemainingPercent(b), isAsc ); case 'cost': return compare( a.purchasePrice ?? 0, b.purchasePrice ?? 0, isAsc ); case 'usage': return compare( a.weightTotalGrams - a.weightRemainingGrams, b.weightTotalGrams - b.weightRemainingGrams, isAsc ); case 'stockLevel': return compare( stockLevelOrder(classifyStockLevel(a)), stockLevelOrder(classifyStockLevel(b)), isAsc ); case 'status': return compare( a.isActive ? 0 : 1, b.isActive ? 0 : 1, isAsc ); default: return 0; } }); 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; /** Template helper: classify stock level */ classifyStockLevel = classifyStockLevel; /** Template helper: stock level icon */ stockLevelIcon(level: StockLevel): string { switch (level) { case 'critical': return 'error'; case 'low': return 'warning'; case 'moderate': return 'info'; case 'healthy': return 'check_circle'; } } /** Template helper: stock level label */ stockLevelLabel(level: StockLevel): string { switch (level) { case 'critical': return 'Critical'; case 'low': return 'Low'; case 'moderate': return 'Moderate'; case 'healthy': return 'Healthy'; } } /** Template helper: format remaining weight */ formatWeight(grams: number): string { if (grams >= 1000) { return `${(grams / 1000).toFixed(1)}kg`; } return `${Math.round(grams)}g`; } /** Template helper: format currency */ formatCurrency(value: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value); } /** Template helper: compute cost per gram for a filament */ getCostPerGram(filament: Filament): number | null { if (filament.purchasePrice === null || filament.purchasePrice === 0 || filament.weightTotalGrams <= 0) { return null; } return filament.purchasePrice / filament.weightTotalGrams; } /** Template helper: compute grams used for a filament */ getGramsUsed(filament: Filament): number { return filament.weightTotalGrams - filament.weightRemainingGrams; } } /** Compare helper for sorting */ function compare(a: number | string, b: number | string, isAsc: boolean): number { return (a < b ? -1 : a > b ? 1 : 0) * (isAsc ? 1 : -1); } /** Stock level sort order (critical=0, healthy=3) */ function stockLevelOrder(level: StockLevel): number { switch (level) { case 'critical': return 0; case 'low': return 1; case 'moderate': return 2; case 'healthy': return 3; } }