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,11 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes) provideRouter(routes),
provideHttpClient(),
] ]
}; };

View File

@@ -5,7 +5,7 @@
<!-- Status Summary Bar — fleet-wide health at a glance --> <!-- Status Summary Bar — fleet-wide health at a glance -->
<app-dashboard-summary></app-dashboard-summary> <app-dashboard-summary></app-dashboard-summary>
<!-- Inventory Summary Bar — filament metrics at a glance --> <!-- Inventory Summary — filament metrics at a glance -->
<app-inventory-summary></app-inventory-summary> <app-inventory-summary></app-inventory-summary>
<!-- Filament Inventory — routed view --> <!-- Filament Inventory — routed view -->

View File

@@ -1,28 +1,108 @@
<!-- Inventory Summary Bar — filament metrics at a glance --> <!-- Inventory Dashboard Summary — filament metrics at a glance -->
<section class="inventory-summary" role="status" aria-label="Inventory summary"> <section class="inventory-summary" role="region" aria-label="Inventory summary">
<!-- Total Filament Count --> <!-- Loading State -->
<div class="summary-item" matTooltip="Total active spools in inventory" matTooltipPosition="below"> @if (loading()) {
<mat-icon aria-hidden="true">inventory_2</mat-icon> <div class="summary-loading" role="status" aria-live="polite">
<span class="metric-value">{{ totalFilamentCount() }}</span> <mat-icon aria-hidden="true" class="spin">sync</mat-icon>
<span class="metric-label">Spools</span> <span>Loading inventory...</span>
</div> </div>
}
<!-- Low Stock Count --> <!-- Error State -->
<div class="summary-item low-stock" @else if (error()) {
[class.has-alerts]="hasLowStock()" <div class="summary-error" role="alert" aria-live="assertive">
[class.has-critical]="hasCriticalStock()" <mat-icon aria-hidden="true">error_outline</mat-icon>
matTooltip="Spools below 25% remaining" matTooltipPosition="below"> <span>{{ error() }}</span>
<mat-icon aria-hidden="true">{{ hasCriticalStock() ? 'error' : hasLowStock() ? 'warning' : 'check_circle' }}</mat-icon> <button class="retry-btn" (click)="refresh()" aria-label="Retry loading inventory">
<span class="metric-value">{{ lowStockCount() }}</span> <mat-icon aria-hidden="true">refresh</mat-icon>
<span class="metric-label">Low Stock</span> Retry
</div> </button>
</div>
}
<!-- Estimated Total Value --> <!-- Loaded State -->
<div class="summary-item" matTooltip="Estimated total value of active spools" matTooltipPosition="below"> @else {
<mat-icon aria-hidden="true">payments</mat-icon> <!-- Health Status Indicator -->
<span class="metric-value">{{ formatCurrency(estimatedTotalValue()) }}</span> <div class="summary-item health-status"
<span class="metric-label">Value</span> [class]="healthClass()"
</div> [matTooltip]="healthLabel()"
matTooltipPosition="below">
<mat-icon aria-hidden="true">
@switch (healthClass()) {
@case ('critical') { error }
@case ('low') { warning }
@default { check_circle }
}
</mat-icon>
<span class="health-text">{{ healthLabel() }}</span>
</div>
<!-- Total Spool Count -->
<div class="summary-item metric-card"
matTooltip="Total filament spools in inventory"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">inventory_2</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ totalCount() }}</span>
<span class="metric-label">Total Spools</span>
</div>
@if (activeCount() < totalCount()) {
<span class="metric-detail">{{ activeCount() }} active</span>
}
</div>
<!-- Low Stock Count -->
<div class="summary-item metric-card"
[class.has-alert]="hasLowStock()"
[class.has-critical]="hasCritical()"
[matTooltip]="hasCritical()
? criticalCount() + ' critical, ' + (lowStockCount() - criticalCount()) + ' low'
: hasLowStock()
? lowStockCount() + ' spools at or below 25% remaining'
: 'All spools above 25% remaining'"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">
@if (hasCritical()) { error }
@else if (hasLowStock()) { warning }
@else { check_circle }
</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ lowStockCount() }}</span>
<span class="metric-label">Low Stock</span>
</div>
@if (hasCritical()) {
<span class="metric-detail critical-detail">{{ criticalCount() }} critical</span>
}
</div>
<!-- Estimated Total Value -->
<div class="summary-item metric-card"
matTooltip="Estimated total value of active spools"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">payments</mat-icon>
<div class="metric-content">
<span class="metric-value">{{ formatCurrency(totalValue()) }}</span>
<span class="metric-label">Est. Value</span>
</div>
</div>
<!-- Overall Remaining Stock Bar -->
<div class="summary-item metric-card stock-bar-card"
matTooltip="{{ formatWeight(totalRemainingGrams()) }} of {{ formatWeight(totalCapacityGrams()) }} remaining"
matTooltipPosition="below">
<mat-icon aria-hidden="true" class="metric-icon">line_weight</mat-icon>
<div class="metric-content stock-bar-content">
<div class="stock-bar-header">
<span class="metric-value">{{ overallRemainingPercent() }}%</span>
<span class="metric-label">Remaining</span>
</div>
<mat-progress-bar
mode="determinate"
[value]="overallRemainingPercent()"
[ngClass]="healthClass()">
</mat-progress-bar>
</div>
</div>
}
</section> </section>

View File

@@ -1,93 +1,257 @@
/** /**
* Inventory Summary Component Styles * Inventory Summary Component Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA * Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
* Consistent with dashboard-summary component styling * Matches the existing dark theme from app.scss
*/ */
// Touch-optimized sizing // Touch-optimized sizing
$touch-target-min: 48px; $touch-target-min: 48px;
$kiosk-font-primary: 20px; $kiosk-font-primary: 24px;
$mobile-font-primary: 16px; $mobile-font-primary: 18px;
$spacing-unit: 8px; $spacing-unit: 8px;
// Status colors — high contrast for workshop/bright environments // Status colors — high contrast for workshop/bright environments
$color-healthy: #4ade70; // Green — stock OK $color-healthy: #4ade70; // Green
$color-low: #facc15; // Amber — low stock $color-low: #fbbf24; // Amber/Yellow
$color-critical: #f87171; // Red — critical stock $color-critical: #f87171; // Red
$color-bg: #1a1a2e; // Matches app.scss
$color-text: #e0e0e0;
$color-text-muted: rgba(255, 255, 255, 0.7);
$color-card-bg: rgba(255, 255, 255, 0.05);
$color-card-border: rgba(255, 255, 255, 0.1);
.inventory-summary { .inventory-summary {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: $spacing-unit * 2; gap: $spacing-unit * 2;
padding: $spacing-unit * 2; padding: $spacing-unit * 2;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: thin;
@media (max-width: 480px) { @media (max-width: 600px) {
flex-wrap: wrap;
padding: $spacing-unit; padding: $spacing-unit;
gap: $spacing-unit; gap: $spacing-unit;
} }
} }
.summary-item { // Health status indicator
.health-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-unit; gap: $spacing-unit;
min-height: $touch-target-min;
padding: $spacing-unit $spacing-unit * 2; padding: $spacing-unit $spacing-unit * 2;
border-radius: 12px; border-radius: 24px;
background-color: rgba(255, 255, 255, 0.05); min-height: $touch-target-min;
white-space: nowrap; white-space: nowrap;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
@media (max-width: 480px) { &.healthy {
padding: $spacing-unit; background-color: rgba($color-healthy, 0.15);
border-radius: 8px; color: $color-healthy;
} }
mat-icon { &.low {
font-size: 22px !important; background-color: rgba($color-low, 0.15);
width: 22px !important; color: $color-low;
height: 22px !important;
} }
.metric-value { &.critical {
font-size: $kiosk-font-primary; background-color: rgba($color-critical, 0.15);
color: $color-critical;
}
.health-text {
font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 1.2; letter-spacing: 0.02em;
@media (max-width: 480px) { @media (max-width: 480px) {
font-size: $mobile-font-primary; font-size: 12px;
} }
} }
.metric-label { mat-icon {
font-size: 12px; font-size: 20px !important;
color: rgba(255, 255, 255, 0.7); width: 20px !important;
text-transform: uppercase; height: 20px !important;
letter-spacing: 0.05em; }
}
@media (max-width: 480px) { // Metric card
font-size: 10px; .metric-card {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit $spacing-unit * 2;
background-color: $color-card-bg;
border: 1px solid $color-card-border;
border-radius: 12px;
min-height: $touch-target-min;
white-space: nowrap;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.08);
}
@media (max-width: 480px) {
padding: $spacing-unit;
}
&.has-alert {
border-color: rgba($color-low, 0.4);
}
&.has-critical {
border-color: rgba($color-critical, 0.5);
background-color: rgba($color-critical, 0.08);
}
}
.metric-icon {
color: $color-text-muted;
font-size: 22px !important;
width: 22px !important;
height: 22px !important;
.has-alert & {
color: $color-low;
}
.has-critical & {
color: $color-critical;
}
}
.metric-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-value {
font-size: $kiosk-font-primary;
font-weight: 700;
line-height: 1.2;
color: $color-text;
@media (max-width: 480px) {
font-size: $mobile-font-primary;
}
}
.metric-label {
font-size: 11px;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-detail {
font-size: 11px;
color: $color-text-muted;
margin-left: $spacing-unit;
&.critical-detail {
color: $color-critical;
font-weight: 600;
}
}
// Stock bar card
.stock-bar-card {
flex: 1 1 200px;
min-width: 180px;
}
.stock-bar-content {
flex: 1;
min-width: 0;
}
.stock-bar-header {
display: flex;
align-items: baseline;
gap: $spacing-unit;
margin-bottom: 4px;
}
// Progress bar color classes
::ng-deep .mat-mdc-progress-bar {
&.healthy .mdc-linear-progress__bar-inner {
background-color: $color-healthy !important;
}
&.low .mdc-linear-progress__bar-inner {
background-color: $color-low !important;
}
&.critical .mdc-linear-progress__bar-inner {
background-color: $color-critical !important;
}
}
// Loading state
.summary-loading {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 2;
color: $color-text-muted;
font-size: 14px;
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Error state
.summary-error {
display: flex;
align-items: center;
gap: $spacing-unit;
padding: $spacing-unit * 2;
background-color: rgba($color-critical, 0.1);
border: 1px solid rgba($color-critical, 0.3);
border-radius: 12px;
color: $color-critical;
font-size: 14px;
.retry-btn {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid rgba($color-critical, 0.4);
color: $color-critical;
padding: 4px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
min-height: $touch-target-min - 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba($color-critical, 0.15);
}
mat-icon {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
} }
} }
} }
// Low stock states // Summary item base
.low-stock { .summary-item {
&.has-alerts { flex-shrink: 0;
background-color: rgba($color-low, 0.15);
mat-icon {
color: $color-low;
}
}
&.has-critical {
background-color: rgba($color-critical, 0.15);
mat-icon {
color: $color-critical;
}
}
} }

View File

@@ -1,24 +1,33 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
OnDestroy,
OnInit,
computed, computed,
inject, inject,
signal,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FilamentService } from '../../services/filament.service'; 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: * Displays:
* - Total filament count (active spools) * - Total filament spool count
* - Low stock count (spools classified as 'low' or 'critical') * - Low stock count (spools ≤25% remaining, i.e. "low" or "critical")
* - Estimated total filament value (sum of purchase prices for active spools) * - 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({ @Component({
selector: 'app-inventory-summary', selector: 'app-inventory-summary',
@@ -26,56 +35,142 @@ import { classifyStockLevel } from '../../models/filament.model';
imports: [ imports: [
CommonModule, CommonModule,
MatIconModule, MatIconModule,
MatChipsModule,
MatTooltipModule, MatTooltipModule,
MatProgressBarModule,
], ],
templateUrl: './inventory-summary.component.html', templateUrl: './inventory-summary.component.html',
styleUrl: './inventory-summary.component.scss', styleUrls: ['./inventory-summary.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class InventorySummaryComponent { export class InventorySummaryComponent implements OnInit, OnDestroy {
private readonly filamentService = inject(FilamentService); private readonly filamentService = inject(FilamentService);
private subscription: Subscription | null = null;
/** Computed: total number of active filament spools */ /** All filament data — reactive signal from shared service */
readonly totalFilamentCount = computed(() => readonly filaments = this.filamentService.filaments;
this.filamentService.filaments().filter((f) => f.isActive).length
/** 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 */ /** Computed: count of low/critical stock spools (≤25% remaining) */
readonly lowStockCount = computed(() => readonly lowStockCount = computed(
this.filamentService.filaments().filter((f) => { () =>
const level = classifyStockLevel(f); this.filaments().filter(
return level === 'low' || level === 'critical'; (f) =>
}).length classifyStockLevel(f) === 'low' ||
classifyStockLevel(f) === 'critical'
).length
); );
/** Computed: count of spools at critical stock level only */ /** Computed: count of critically low spools (≤10% remaining) */
readonly criticalStockCount = computed(() => readonly criticalCount = computed(
this.filamentService.filaments().filter( () =>
(f) => classifyStockLevel(f) === 'critical' this.filaments().filter((f) => classifyStockLevel(f) === 'critical')
).length .length
); );
/** Computed: estimated total value of all active spools with a recorded price */ /** Computed: estimated total value of active spools */
readonly estimatedTotalValue = computed(() => readonly totalValue = computed(() =>
this.filamentService this.filaments()
.filaments()
.filter((f) => f.isActive && f.purchasePrice !== null) .filter((f) => f.isActive && f.purchasePrice !== null)
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0) .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); readonly hasLowStock = computed(() => this.lowStockCount() > 0);
/** Computed: whether there are critical-stock spools */ /** Computed: whether to show a critical-stock alert */
readonly hasCriticalStock = computed(() => this.criticalStockCount() > 0); 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 { formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: 'USD', currency: 'USD',
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 2, maximumFractionDigits: 0,
}).format(value); }).format(value);
} }
} }

View File

@@ -1,176 +1,52 @@
import { Injectable, signal, computed } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Filament, classifyStockLevel } from '../models/filament.model'; import { HttpClient } from '@angular/common/http';
import { Observable, Subscription } from 'rxjs';
import { signal } from '@angular/core';
import { Filament } from '../models/filament.model';
/** /**
* FilamentService — shared reactive state for filament inventory. * API base URL — matches the Extrudex backend.
* * TODO: Move to environment config when environments are set up.
* Provides a single source of truth for filament data across components.
* Both FilamentTableComponent and InventorySummaryComponent read from this service.
* Data is updated via setFilaments() — called by SignalR handlers or HTTP load.
*/ */
@Injectable({ const API_BASE_URL = '/api/filaments';
providedIn: 'root',
}) /**
* Service for managing filament inventory data.
*
* Provides:
* - A reactive `filaments` signal for components to bind to
* - REST methods for GET, POST, DELETE endpoints
* - Real-time updates via SignalR should be layered on top when the hub is ready
*/
@Injectable({ providedIn: 'root' })
export class FilamentService { export class FilamentService {
/** Primary data store — reactive signal */ private readonly http = inject(HttpClient);
private readonly _filaments = signal<Filament[]>([
{
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: '',
},
]);
/** Public read-only view of all filaments */ /** Reactive filament data — components read from this signal */
readonly filaments = this._filaments.asReadonly(); readonly filaments = signal<Filament[]>([]);
/** /** Fetch all filament spools and update the signal */
* Replace the full filament list. getFilaments(): Observable<Filament[]> {
* Called by SignalR handlers, HTTP responses, or test setup. const req = this.http.get<Filament[]>(API_BASE_URL);
*/ req.subscribe({
setFilaments(data: Filament[]): void { next: (data) => this.filaments.set(data),
this._filaments.set(data); error: (err) => console.error('Failed to load filaments:', err),
}
/**
* Update or insert a single filament by id.
* Called when a real-time SignalR update arrives for one spool.
*/
upsertFilament(updated: Filament): void {
this._filaments.update((current) => {
const idx = current.findIndex((f) => f.id === updated.id);
if (idx === -1) {
return [...current, updated];
}
const copy = [...current];
copy[idx] = updated;
return copy;
}); });
return req;
} }
/** /** Fetch a single filament by ID */
* Remove a filament by id. getFilament(id: string): Observable<Filament> {
* Called when a spool is deleted via real-time event. return this.http.get<Filament>(`${API_BASE_URL}/${id}`);
*/
removeFilament(id: string): void {
this._filaments.update((current) => current.filter((f) => f.id !== id));
} }
/** Computed: count of active spools */ /** Set filament data directly — used by components or SignalR handlers */
readonly activeCount = computed(() => setFilaments(data: Filament[]): void {
this._filaments().filter((f) => f.isActive).length this.filaments.set(data);
); }
/** Computed: count of low or critical stock spools */ /** Delete a filament spool by ID */
readonly lowStockCount = computed(() => deleteFilament(id: string): Observable<void> {
this._filaments().filter((f) => { return this.http.delete<void>(`${API_BASE_URL}/${id}`);
const level = classifyStockLevel(f); }
return level === 'low' || level === 'critical'; }
}).length
);
}