All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s
304 lines
8.3 KiB
TypeScript
304 lines
8.3 KiB
TypeScript
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<FilamentColumn[]>([
|
|
'color',
|
|
'material',
|
|
'brand',
|
|
'serial',
|
|
'remaining',
|
|
'cost',
|
|
'usage',
|
|
'stockLevel',
|
|
'status',
|
|
]);
|
|
|
|
/** Default columns for template binding */
|
|
readonly columns = this._displayedColumns;
|
|
|
|
/** Current filter state */
|
|
readonly filterState = signal<FilamentFilterState>({
|
|
materialBaseNames: [],
|
|
colorSearch: '',
|
|
lowStockOnly: false,
|
|
activeOnly: false,
|
|
});
|
|
|
|
/** Sorted filament data */
|
|
readonly sortedFilaments = signal<Filament[]>([]);
|
|
|
|
/** 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;
|
|
}
|
|
} |