From e56aa3ba391a029e8aaaf98e55699d232c4b246e Mon Sep 17 00:00:00 2001 From: rex-bot Date: Mon, 27 Apr 2026 18:11:30 -0400 Subject: [PATCH] CUB-43: add inventory dashboard summary component with FilamentService --- frontend/src/app/app.html | 3 + frontend/src/app/app.ts | 3 +- .../filament-table.component.ts | 136 ++------------ .../inventory-summary.component.html | 28 +++ .../inventory-summary.component.scss | 93 +++++++++ .../inventory-summary.component.ts | 81 ++++++++ frontend/src/app/services/filament.service.ts | 176 ++++++++++++++++++ 7 files changed, 396 insertions(+), 124 deletions(-) create mode 100644 frontend/src/app/components/inventory-summary/inventory-summary.component.html create mode 100644 frontend/src/app/components/inventory-summary/inventory-summary.component.scss create mode 100644 frontend/src/app/components/inventory-summary/inventory-summary.component.ts create mode 100644 frontend/src/app/services/filament.service.ts diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 73caf97..f7981e7 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -5,6 +5,9 @@ + + + \ No newline at end of file diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 12dd36a..8df705c 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,11 +1,12 @@ import { Component, ViewChild } from '@angular/core'; import { RouterOutlet } from '@angular/router'; 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'; @Component({ selector: 'app-root', - imports: [RouterOutlet, DashboardSummaryComponent], + imports: [RouterOutlet, DashboardSummaryComponent, InventorySummaryComponent], templateUrl: './app.html', styleUrl: './app.scss' }) diff --git a/frontend/src/app/components/filament-table/filament-table.component.ts b/frontend/src/app/components/filament-table/filament-table.component.ts index f20b831..685c06d 100644 --- a/frontend/src/app/components/filament-table/filament-table.component.ts +++ b/frontend/src/app/components/filament-table/filament-table.component.ts @@ -2,9 +2,12 @@ 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'; @@ -47,9 +50,11 @@ export type FilamentColumn = styleUrl: './filament-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FilamentTableComponent { - /** Filament data input — reactive signal for live updates */ - readonly filaments = signal([]); +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() @@ -102,129 +107,14 @@ export class FilamentTableComponent { this.filaments().filter((f) => classifyStockLevel(f) === 'critical').length ); - constructor() { - // Initialize sorted data from filaments - // (MatSort handles sorting via sortChange; we start unsorted) - - // 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: '', - }, - ]); + ngOnInit(): void { + // Initialize sorted data from FilamentService + this.sortedFilaments.set([...this.filaments()]); } - /** Update filament data — called by parent or service */ + /** Update filament data — called externally or from a SignalR handler */ updateFilaments(data: Filament[]): void { - this.filaments.set(data); + this.filamentService.setFilaments(data); this.sortedFilaments.set([...data]); } diff --git a/frontend/src/app/components/inventory-summary/inventory-summary.component.html b/frontend/src/app/components/inventory-summary/inventory-summary.component.html new file mode 100644 index 0000000..d5c6c45 --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.html @@ -0,0 +1,28 @@ + +
+ + +
+ + {{ totalFilamentCount() }} + Spools +
+ + +
+ + {{ lowStockCount() }} + Low Stock +
+ + +
+ + {{ formatCurrency(estimatedTotalValue()) }} + Value +
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/inventory-summary/inventory-summary.component.scss b/frontend/src/app/components/inventory-summary/inventory-summary.component.scss new file mode 100644 index 0000000..65226f3 --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/inventory-summary/inventory-summary.component.ts b/frontend/src/app/components/inventory-summary/inventory-summary.component.ts new file mode 100644 index 0000000..9ada5ec --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.ts @@ -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); + } +} diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts new file mode 100644 index 0000000..417b361 --- /dev/null +++ b/frontend/src/app/services/filament.service.ts @@ -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([ + { + 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 + ); +}