CUB-43: Add inventory dashboard summary component
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m19s

This commit is contained in:
2026-04-27 21:10:16 -04:00
committed by rex-bot
parent 5ede6a8eb6
commit b7e61fab8a
6 changed files with 485 additions and 268 deletions

View File

@@ -1,24 +1,33 @@
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 {
classifyStockLevel,
} from '../../models/filament.model';
import { Subscription } from 'rxjs';
/**
* InventorySummaryComponent — dashboard summary for filament inventory metrics.
* Inventory Dashboard Summary — shows filament inventory at a glance.
*
* Displays three key metrics driven by FilamentService:
* - Total filament count (active spools)
* - Low stock count (spools classified as 'low' or 'critical')
* 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)
*
* All values update dynamically whenever FilamentService data changes.
* Data is sourced from the shared FilamentService signal,
* which is loaded on init and can be refreshed via refresh().
*/
@Component({
selector: 'app-inventory-summary',
@@ -26,56 +35,142 @@ import { classifyStockLevel } from '../../models/filament.model';
imports: [
CommonModule,
MatIconModule,
MatChipsModule,
MatTooltipModule,
MatProgressBarModule,
],
templateUrl: './inventory-summary.component.html',
styleUrl: './inventory-summary.component.scss',
styleUrls: ['./inventory-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InventorySummaryComponent {
export class InventorySummaryComponent implements OnInit, OnDestroy {
private readonly filamentService = inject(FilamentService);
private subscription: Subscription | null = null;
/** Computed: total number of active filament spools */
readonly totalFilamentCount = computed(() =>
this.filamentService.filaments().filter((f) => f.isActive).length
/** 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 spools at low or critical stock levels */
readonly lowStockCount = computed(() =>
this.filamentService.filaments().filter((f) => {
const level = classifyStockLevel(f);
return level === 'low' || level === 'critical';
}).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 spools at critical stock level only */
readonly criticalStockCount = computed(() =>
this.filamentService.filaments().filter(
(f) => 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 all active spools with a recorded price */
readonly estimatedTotalValue = computed(() =>
this.filamentService
.filaments()
/** 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: whether there are low-stock spools to highlight */
/** 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 there are critical-stock spools */
readonly hasCriticalStock = computed(() => this.criticalStockCount() > 0);
/** Computed: whether to show a critical-stock alert */
readonly hasCritical = computed(() => this.criticalCount() > 0);
/** Format a currency value for display */
/** 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: 0,
maximumFractionDigits: 2,
maximumFractionDigits: 0,
}).format(value);
}
}
}