diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..a5545e8 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,11 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) + provideRouter(routes), + provideHttpClient(), ] }; diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 73caf97..1be9e43 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..fc49650 --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.html @@ -0,0 +1,108 @@ + +
+ + + @if (loading()) { +
+ + Loading inventory... +
+ } + + + @else if (error()) { + + } + + + @else { + +
+ + {{ healthLabel() }} +
+ + +
+ +
+ {{ totalCount() }} + Total Spools +
+ @if (activeCount() < totalCount()) { + {{ activeCount() }} active + } +
+ + +
+ +
+ {{ lowStockCount() }} + Low Stock +
+ @if (hasCritical()) { + {{ criticalCount() }} critical + } +
+ + +
+ +
+ {{ formatCurrency(totalValue()) }} + Est. Value +
+
+ + +
+ +
+
+ {{ overallRemainingPercent() }}% + Remaining +
+ + +
+
+ } +
\ 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..6921714 --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.scss @@ -0,0 +1,257 @@ +/** + * Inventory Summary Component Styles + * Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA + * Matches the existing dark theme from app.scss + */ + +// Touch-optimized sizing +$touch-target-min: 48px; +$kiosk-font-primary: 24px; +$mobile-font-primary: 18px; +$spacing-unit: 8px; + +// Status colors — high contrast for workshop/bright environments +$color-healthy: #4ade70; // Green +$color-low: #fbbf24; // Amber/Yellow +$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 { + display: flex; + align-items: stretch; + gap: $spacing-unit * 2; + padding: $spacing-unit * 2; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + + @media (max-width: 600px) { + flex-wrap: wrap; + padding: $spacing-unit; + gap: $spacing-unit; + } +} + +// Health status indicator +.health-status { + display: flex; + align-items: center; + gap: $spacing-unit; + padding: $spacing-unit $spacing-unit * 2; + border-radius: 24px; + min-height: $touch-target-min; + white-space: nowrap; + transition: background-color 0.3s ease; + + &.healthy { + background-color: rgba($color-healthy, 0.15); + color: $color-healthy; + } + + &.low { + background-color: rgba($color-low, 0.15); + color: $color-low; + } + + &.critical { + background-color: rgba($color-critical, 0.15); + color: $color-critical; + } + + .health-text { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.02em; + + @media (max-width: 480px) { + font-size: 12px; + } + } + + mat-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + } +} + +// Metric card +.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; + } + } +} + +// Summary item base +.summary-item { + flex-shrink: 0; +} \ 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..9c89055 --- /dev/null +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.ts @@ -0,0 +1,176 @@ +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(true); + + /** Error state */ + readonly error = signal(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: 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: 0, + maximumFractionDigits: 0, + }).format(value); + } +} \ No newline at end of file diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts new file mode 100644 index 0000000..88c59c3 --- /dev/null +++ b/frontend/src/app/services/filament.service.ts @@ -0,0 +1,52 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, Subscription } from 'rxjs'; +import { signal } from '@angular/core'; +import { Filament } from '../models/filament.model'; + +/** + * API base URL — matches the Extrudex backend. + * TODO: Move to environment config when environments are set up. + */ +const API_BASE_URL = '/api/filaments'; + +/** + * 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 { + private readonly http = inject(HttpClient); + + /** Reactive filament data — components read from this signal */ + readonly filaments = signal([]); + + /** Fetch all filament spools and update the signal */ + getFilaments(): Observable { + const req = this.http.get(API_BASE_URL); + req.subscribe({ + next: (data) => this.filaments.set(data), + error: (err) => console.error('Failed to load filaments:', err), + }); + return req; + } + + /** Fetch a single filament by ID */ + getFilament(id: string): Observable { + return this.http.get(`${API_BASE_URL}/${id}`); + } + + /** Set filament data directly — used by components or SignalR handlers */ + setFilaments(data: Filament[]): void { + this.filaments.set(data); + } + + /** Delete a filament spool by ID */ + deleteFilament(id: string): Observable { + return this.http.delete(`${API_BASE_URL}/${id}`); + } +} \ No newline at end of file