import { ChangeDetectionStrategy, Component, Input, computed, inject, signal, } from '@angular/core'; 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 { 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 = | 'color' | 'material' | 'brand' | 'serial' | 'remaining' | 'stockLevel' | 'status' | 'actions'; @Component({ selector: 'app-filament-table', standalone: true, imports: [ CommonModule, MatTableModule, MatChipsModule, MatIconModule, 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([]); /** 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); } get displayedColumns(): FilamentColumn[] { return this._displayedColumns(); } private readonly _displayedColumns = signal([ 'color', 'material', 'brand', 'serial', 'remaining', 'stockLevel', 'status', 'actions', ]); /** Default columns for template binding */ readonly columns = this._displayedColumns; /** Sorted filament data */ readonly sortedFilaments = signal([]); /** 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 ); constructor() { // Initialize sorted data from filaments // (MatSort handles sorting via sortChange; we start unsorted) // Development: seed with sample data for visual testing // TODO: Replace with service data from FilamentService / SignalR this.updateFilaments([ { id: '1', materialBaseId: 'm1', materialBaseName: 'PLA', materialFinishId: 'f1', materialFinishName: 'Basic', materialModifierId: null, materialModifierName: null, brand: 'Bambu Lab', colorName: 'White', colorHex: '#F5F5F5', weightTotalGrams: 1000, weightRemainingGrams: 850, filamentDiameterMm: 1.75, spoolSerial: 'SN-001', purchasePrice: 25.00, purchaseDate: '2026-01-15T00:00:00Z', isActive: true, createdAt: '2026-01-15T00:00:00Z', updatedAt: '2026-04-20T00:00:00Z', qrCodeUrl: '', }, { id: '2', materialBaseId: 'm2', materialBaseName: 'PETG', materialFinishId: 'f2', materialFinishName: 'Matte', materialModifierId: 'mod1', materialModifierName: 'Carbon Fiber', brand: 'Polymaker', colorName: 'Fire Engine Red', colorHex: '#FF0000', weightTotalGrams: 1000, weightRemainingGrams: 80, filamentDiameterMm: 1.75, spoolSerial: 'SN-002', purchasePrice: 35.00, purchaseDate: '2026-02-01T00:00:00Z', isActive: true, createdAt: '2026-02-01T00:00:00Z', updatedAt: '2026-04-25T00:00:00Z', qrCodeUrl: '', }, { id: '3', materialBaseId: 'm1', materialBaseName: 'PLA', materialFinishId: 'f1', materialFinishName: 'Basic', materialModifierId: null, materialModifierName: null, brand: 'eSun', colorName: 'Sky Blue', colorHex: '#87CEEB', weightTotalGrams: 1000, weightRemainingGrams: 200, filamentDiameterMm: 1.75, spoolSerial: 'SN-003', purchasePrice: 20.00, purchaseDate: '2026-03-10T00:00:00Z', isActive: true, createdAt: '2026-03-10T00:00:00Z', updatedAt: '2026-04-26T00:00:00Z', qrCodeUrl: '', }, { id: '4', materialBaseId: 'm3', materialBaseName: 'ABS', materialFinishId: 'f1', materialFinishName: 'Basic', materialModifierId: null, materialModifierName: null, brand: 'Hatchbox', colorName: 'Black', colorHex: '#1A1A1A', weightTotalGrams: 1000, weightRemainingGrams: 450, filamentDiameterMm: 1.75, spoolSerial: 'SN-004', purchasePrice: 22.00, purchaseDate: null, isActive: true, createdAt: '2026-01-20T00:00:00Z', updatedAt: '2026-04-18T00:00:00Z', qrCodeUrl: '', }, { id: '5', materialBaseId: 'm1', materialBaseName: 'PLA', materialFinishId: 'f3', materialFinishName: 'Silk', materialModifierId: null, materialModifierName: null, brand: 'Overturn', colorName: 'Gold', colorHex: '#FFD700', weightTotalGrams: 500, weightRemainingGrams: 15, filamentDiameterMm: 1.75, spoolSerial: 'SN-005', purchasePrice: 28.00, purchaseDate: null, isActive: false, createdAt: '2025-12-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', qrCodeUrl: '', }, ]); } /** Update filament data — called by parent or service */ updateFilaments(data: Filament[]): void { this.filaments.set(data); this.sortedFilaments.set([...data]); } /** 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 '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); } /** * 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; /** 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`; } } /** 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; } }