383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
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<Filament[]>([]);
|
|
|
|
/** Whether a delete operation is in progress */
|
|
readonly deleting = signal<string | null>(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<FilamentColumn[]>([
|
|
'color',
|
|
'material',
|
|
'brand',
|
|
'serial',
|
|
'remaining',
|
|
'stockLevel',
|
|
'status',
|
|
'actions',
|
|
]);
|
|
|
|
/** Default columns for template binding */
|
|
readonly columns = this._displayedColumns;
|
|
|
|
/** Sorted filament data */
|
|
readonly sortedFilaments = signal<Filament[]>([]);
|
|
|
|
/** 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;
|
|
}
|
|
} |