All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s
207 lines
6.4 KiB
TypeScript
207 lines
6.4 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
OnDestroy,
|
|
OnInit,
|
|
computed,
|
|
inject,
|
|
signal,
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatChipsModule } from '@angular/material/chips';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
import { FilamentService } from '../../services/filament.service';
|
|
import {
|
|
classifyStockLevel,
|
|
} from '../../models/filament.model';
|
|
import { Subscription } from 'rxjs';
|
|
|
|
/**
|
|
* Inventory Dashboard Summary — shows filament inventory at a glance.
|
|
*
|
|
* Displays:
|
|
* - Total filament spool count
|
|
* - Low stock count (spools ≤25% remaining, i.e. "low" or "critical")
|
|
* - Estimated total filament value (sum of purchase prices for active spools)
|
|
*
|
|
* Data is sourced from the shared FilamentService signal,
|
|
* which is loaded on init and can be refreshed via refresh().
|
|
*/
|
|
@Component({
|
|
selector: 'app-inventory-summary',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
MatIconModule,
|
|
MatChipsModule,
|
|
MatTooltipModule,
|
|
MatProgressBarModule,
|
|
],
|
|
templateUrl: './inventory-summary.component.html',
|
|
styleUrls: ['./inventory-summary.component.scss'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class InventorySummaryComponent implements OnInit, OnDestroy {
|
|
private readonly filamentService = inject(FilamentService);
|
|
private subscription: Subscription | null = null;
|
|
|
|
/** All filament data — reactive signal from shared service */
|
|
readonly filaments = this.filamentService.filaments;
|
|
|
|
/** Loading state */
|
|
readonly loading = signal<boolean>(true);
|
|
|
|
/** Error state */
|
|
readonly error = signal<string | null>(null);
|
|
|
|
/** Computed: total number of filament spools */
|
|
readonly totalCount = computed(() => this.filaments().length);
|
|
|
|
/** Computed: count of active spools */
|
|
readonly activeCount = computed(
|
|
() => this.filaments().filter((f) => f.isActive).length
|
|
);
|
|
|
|
/** Computed: count of low/critical stock spools (≤25% remaining) */
|
|
readonly lowStockCount = computed(
|
|
() =>
|
|
this.filaments().filter(
|
|
(f) =>
|
|
classifyStockLevel(f) === 'low' ||
|
|
classifyStockLevel(f) === 'critical'
|
|
).length
|
|
);
|
|
|
|
/** Computed: count of critically low spools (≤10% remaining) */
|
|
readonly criticalCount = computed(
|
|
() =>
|
|
this.filaments().filter((f) => classifyStockLevel(f) === 'critical')
|
|
.length
|
|
);
|
|
|
|
/** Computed: estimated total value of active spools */
|
|
readonly totalValue = computed(() =>
|
|
this.filaments()
|
|
.filter((f) => f.isActive && f.purchasePrice !== null)
|
|
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0)
|
|
);
|
|
|
|
/** Computed: average cost per gram across active spools with a price */
|
|
readonly avgCostPerGram = computed(() => {
|
|
const priced = this.filaments().filter(
|
|
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
|
|
);
|
|
if (priced.length === 0) return null;
|
|
const totalCost = priced.reduce((sum, f) => sum + f.purchasePrice!, 0);
|
|
const totalWeight = priced.reduce((sum, f) => sum + f.weightTotalGrams, 0);
|
|
return totalWeight > 0 ? totalCost / totalWeight : null;
|
|
});
|
|
|
|
/** Computed: total grams used across all spools */
|
|
readonly totalGramsUsed = computed(() =>
|
|
this.filaments().reduce(
|
|
(sum, f) => sum + (f.weightTotalGrams - f.weightRemainingGrams),
|
|
0
|
|
)
|
|
);
|
|
|
|
/** Computed: total estimated value of used filament */
|
|
readonly estimatedUsedValue = computed(() => {
|
|
const priced = this.filaments().filter(
|
|
(f) => f.isActive && f.purchasePrice !== null && f.purchasePrice! > 0 && f.weightTotalGrams > 0
|
|
);
|
|
if (priced.length === 0) return null;
|
|
return priced.reduce((sum, f) => {
|
|
const usedFraction = (f.weightTotalGrams - f.weightRemainingGrams) / f.weightTotalGrams;
|
|
return sum + f.purchasePrice! * usedFraction;
|
|
}, 0);
|
|
});
|
|
|
|
/** Computed: total remaining weight across all spools in grams */
|
|
readonly totalRemainingGrams = computed(() =>
|
|
this.filaments().reduce((sum, f) => sum + f.weightRemainingGrams, 0)
|
|
);
|
|
|
|
/** Computed: total capacity weight across all spools in grams */
|
|
readonly totalCapacityGrams = computed(() =>
|
|
this.filaments().reduce((sum, f) => sum + f.weightTotalGrams, 0)
|
|
);
|
|
|
|
/** Computed: overall remaining percentage */
|
|
readonly overallRemainingPercent = computed(() => {
|
|
const capacity = this.totalCapacityGrams();
|
|
if (capacity <= 0) return 0;
|
|
return Math.round(
|
|
(this.totalRemainingGrams() / capacity) * 100
|
|
);
|
|
});
|
|
|
|
/** Computed: whether to show a low-stock alert */
|
|
readonly hasLowStock = computed(() => this.lowStockCount() > 0);
|
|
|
|
/** Computed: whether to show a critical-stock alert */
|
|
readonly hasCritical = computed(() => this.criticalCount() > 0);
|
|
|
|
/** Computed: status label for the inventory health */
|
|
readonly healthLabel = computed(() => {
|
|
if (this.hasCritical()) return 'Critical Stock';
|
|
if (this.hasLowStock()) return 'Low Stock Alert';
|
|
return 'Stock Healthy';
|
|
});
|
|
|
|
/** Computed: health status color class */
|
|
readonly healthClass = computed(() => {
|
|
if (this.hasCritical()) return 'critical';
|
|
if (this.hasLowStock()) return 'low';
|
|
return 'healthy';
|
|
});
|
|
|
|
ngOnInit(): void {
|
|
this.loadFilaments();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.subscription?.unsubscribe();
|
|
}
|
|
|
|
/** Load filament data from the API via FilamentService */
|
|
loadFilaments(): void {
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
this.subscription = this.filamentService.getFilaments().subscribe({
|
|
next: () => {
|
|
this.loading.set(false);
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to load filaments:', err);
|
|
this.error.set('Failed to load inventory data');
|
|
this.loading.set(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Refresh data — called externally when data changes (e.g., SignalR notification) */
|
|
refresh(): void {
|
|
this.loadFilaments();
|
|
}
|
|
|
|
/** Format weight for display */
|
|
formatWeight(grams: number): string {
|
|
if (grams >= 1000) {
|
|
return `${(grams / 1000).toFixed(1)}kg`;
|
|
}
|
|
return `${Math.round(grams)}g`;
|
|
}
|
|
|
|
/** Format currency for display */
|
|
formatCurrency(value: number): string {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(value);
|
|
}
|
|
} |