CUB-43: add inventory dashboard summary component with FilamentService

This commit is contained in:
2026-04-27 18:11:30 -04:00
parent f70495a85c
commit e56aa3ba39
7 changed files with 396 additions and 124 deletions

View File

@@ -5,6 +5,9 @@
<!-- 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 -->
<app-inventory-summary></app-inventory-summary>
<!-- Filament Inventory — routed view --> <!-- Filament Inventory — routed view -->
<router-outlet /> <router-outlet />
</main> </main>

View File

@@ -1,11 +1,12 @@
import { Component, ViewChild } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { DashboardSummaryComponent } from './components/dashboard-summary/dashboard-summary.component'; import { DashboardSummaryComponent } from './components/dashboard-summary/dashboard-summary.component';
import { InventorySummaryComponent } from './components/inventory-summary/inventory-summary.component';
import { AgentSummary, SystemHealth } from './models/agent.model'; import { AgentSummary, SystemHealth } from './models/agent.model';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, DashboardSummaryComponent], imports: [RouterOutlet, DashboardSummaryComponent, InventorySummaryComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })

View File

@@ -2,9 +2,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input, Input,
OnInit,
computed, computed,
inject,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { FilamentService } from '../../services/filament.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
@@ -47,9 +50,11 @@ export type FilamentColumn =
styleUrl: './filament-table.component.scss', styleUrl: './filament-table.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FilamentTableComponent { export class FilamentTableComponent implements OnInit {
/** Filament data input — reactive signal for live updates */ private readonly filamentService = inject(FilamentService);
readonly filaments = signal<Filament[]>([]);
/** Filament data — reactive signal driven by FilamentService */
readonly filaments = this.filamentService.filaments;
/** Columns to display — defaults to all columns */ /** Columns to display — defaults to all columns */
@Input() @Input()
@@ -102,129 +107,14 @@ export class FilamentTableComponent {
this.filaments().filter((f) => classifyStockLevel(f) === 'critical').length this.filaments().filter((f) => classifyStockLevel(f) === 'critical').length
); );
constructor() { ngOnInit(): void {
// Initialize sorted data from filaments // Initialize sorted data from FilamentService
// (MatSort handles sorting via sortChange; we start unsorted) this.sortedFilaments.set([...this.filaments()]);
// 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 */ /** Update filament data — called externally or from a SignalR handler */
updateFilaments(data: Filament[]): void { updateFilaments(data: Filament[]): void {
this.filaments.set(data); this.filamentService.setFilaments(data);
this.sortedFilaments.set([...data]); this.sortedFilaments.set([...data]);
} }

View File

@@ -0,0 +1,28 @@
<!-- Inventory Summary Bar — filament metrics at a glance -->
<section class="inventory-summary" role="status" aria-label="Inventory summary">
<!-- Total Filament Count -->
<div class="summary-item" matTooltip="Total active spools in inventory" matTooltipPosition="below">
<mat-icon aria-hidden="true">inventory_2</mat-icon>
<span class="metric-value">{{ totalFilamentCount() }}</span>
<span class="metric-label">Spools</span>
</div>
<!-- Low Stock Count -->
<div class="summary-item low-stock"
[class.has-alerts]="hasLowStock()"
[class.has-critical]="hasCriticalStock()"
matTooltip="Spools below 25% remaining" matTooltipPosition="below">
<mat-icon aria-hidden="true">{{ hasCriticalStock() ? 'error' : hasLowStock() ? 'warning' : 'check_circle' }}</mat-icon>
<span class="metric-value">{{ lowStockCount() }}</span>
<span class="metric-label">Low Stock</span>
</div>
<!-- Estimated Total Value -->
<div class="summary-item" matTooltip="Estimated total value of active spools" matTooltipPosition="below">
<mat-icon aria-hidden="true">payments</mat-icon>
<span class="metric-value">{{ formatCurrency(estimatedTotalValue()) }}</span>
<span class="metric-label">Value</span>
</div>
</section>

View File

@@ -0,0 +1,93 @@
/**
* Inventory Summary Component Styles
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
* Consistent with dashboard-summary component styling
*/
// Touch-optimized sizing
$touch-target-min: 48px;
$kiosk-font-primary: 20px;
$mobile-font-primary: 16px;
$spacing-unit: 8px;
// Status colors — high contrast for workshop/bright environments
$color-healthy: #4ade70; // Green — stock OK
$color-low: #facc15; // Amber — low stock
$color-critical: #f87171; // Red — critical stock
.inventory-summary {
display: flex;
align-items: center;
gap: $spacing-unit * 2;
padding: $spacing-unit * 2;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@media (max-width: 480px) {
padding: $spacing-unit;
gap: $spacing-unit;
}
}
.summary-item {
display: flex;
align-items: center;
gap: $spacing-unit;
min-height: $touch-target-min;
padding: $spacing-unit $spacing-unit * 2;
border-radius: 12px;
background-color: rgba(255, 255, 255, 0.05);
white-space: nowrap;
transition: background-color 0.3s ease;
@media (max-width: 480px) {
padding: $spacing-unit;
border-radius: 8px;
}
mat-icon {
font-size: 22px !important;
width: 22px !important;
height: 22px !important;
}
.metric-value {
font-size: $kiosk-font-primary;
font-weight: 600;
line-height: 1.2;
@media (max-width: 480px) {
font-size: $mobile-font-primary;
}
}
.metric-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
@media (max-width: 480px) {
font-size: 10px;
}
}
}
// Low stock states
.low-stock {
&.has-alerts {
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

@@ -0,0 +1,81 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FilamentService } from '../../services/filament.service';
import { classifyStockLevel } from '../../models/filament.model';
/**
* InventorySummaryComponent — dashboard summary for filament inventory metrics.
*
* Displays three key metrics driven by FilamentService:
* - Total filament count (active spools)
* - Low stock count (spools classified as 'low' or 'critical')
* - Estimated total filament value (sum of purchase prices for active spools)
*
* All values update dynamically whenever FilamentService data changes.
*/
@Component({
selector: 'app-inventory-summary',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatTooltipModule,
],
templateUrl: './inventory-summary.component.html',
styleUrl: './inventory-summary.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InventorySummaryComponent {
private readonly filamentService = inject(FilamentService);
/** Computed: total number of active filament spools */
readonly totalFilamentCount = computed(() =>
this.filamentService.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 spools at critical stock level only */
readonly criticalStockCount = computed(() =>
this.filamentService.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()
.filter((f) => f.isActive && f.purchasePrice !== null)
.reduce((sum, f) => sum + (f.purchasePrice ?? 0), 0)
);
/** Computed: whether there are low-stock spools to highlight */
readonly hasLowStock = computed(() => this.lowStockCount() > 0);
/** Computed: whether there are critical-stock spools */
readonly hasCriticalStock = computed(() => this.criticalStockCount() > 0);
/** Format a currency value for display */
formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(value);
}
}

View File

@@ -0,0 +1,176 @@
import { Injectable, signal, computed } from '@angular/core';
import { Filament, classifyStockLevel } from '../models/filament.model';
/**
* FilamentService — shared reactive state for filament inventory.
*
* 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({
providedIn: 'root',
})
export class FilamentService {
/** Primary data store — reactive signal */
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 */
readonly filaments = this._filaments.asReadonly();
/**
* Replace the full filament list.
* Called by SignalR handlers, HTTP responses, or test setup.
*/
setFilaments(data: Filament[]): void {
this._filaments.set(data);
}
/**
* 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;
});
}
/**
* Remove a filament by id.
* Called when a spool is deleted via real-time event.
*/
removeFilament(id: string): void {
this._filaments.update((current) => current.filter((f) => f.id !== id));
}
/** Computed: count of active spools */
readonly activeCount = computed(() =>
this._filaments().filter((f) => f.isActive).length
);
/** Computed: count of low or critical stock spools */
readonly lowStockCount = computed(() =>
this._filaments().filter((f) => {
const level = classifyStockLevel(f);
return level === 'low' || level === 'critical';
}).length
);
}