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 f7981e7..1be9e43 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -5,7 +5,7 @@ - + diff --git a/frontend/src/app/components/inventory-summary/inventory-summary.component.html b/frontend/src/app/components/inventory-summary/inventory-summary.component.html index d5c6c45..fc49650 100644 --- a/frontend/src/app/components/inventory-summary/inventory-summary.component.html +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.html @@ -1,28 +1,108 @@ - -
+ +
- -
- - {{ totalFilamentCount() }} - Spools -
+ + @if (loading()) { +
+ + Loading inventory... +
+ } - -
- - {{ lowStockCount() }} - Low Stock -
+ + @else if (error()) { + + } - -
- - {{ formatCurrency(estimatedTotalValue()) }} - Value -
+ + @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 index 65226f3..6921714 100644 --- a/frontend/src/app/components/inventory-summary/inventory-summary.component.scss +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.scss @@ -1,93 +1,257 @@ /** * Inventory Summary Component Styles * 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-target-min: 48px; -$kiosk-font-primary: 20px; -$mobile-font-primary: 16px; +$kiosk-font-primary: 24px; +$mobile-font-primary: 18px; $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 +$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: center; + align-items: stretch; gap: $spacing-unit * 2; padding: $spacing-unit * 2; overflow-x: auto; -webkit-overflow-scrolling: touch; + scrollbar-width: thin; - @media (max-width: 480px) { + @media (max-width: 600px) { + flex-wrap: wrap; padding: $spacing-unit; gap: $spacing-unit; } } -.summary-item { +// Health status indicator +.health-status { 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); + border-radius: 24px; + min-height: $touch-target-min; white-space: nowrap; transition: background-color 0.3s ease; - @media (max-width: 480px) { - padding: $spacing-unit; - border-radius: 8px; + &.healthy { + background-color: rgba($color-healthy, 0.15); + color: $color-healthy; } - mat-icon { - font-size: 22px !important; - width: 22px !important; - height: 22px !important; + &.low { + background-color: rgba($color-low, 0.15); + color: $color-low; } - .metric-value { - font-size: $kiosk-font-primary; + &.critical { + background-color: rgba($color-critical, 0.15); + color: $color-critical; + } + + .health-text { + font-size: 14px; font-weight: 600; - line-height: 1.2; + letter-spacing: 0.02em; @media (max-width: 480px) { - font-size: $mobile-font-primary; + font-size: 12px; } } - .metric-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); - text-transform: uppercase; - letter-spacing: 0.05em; + mat-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + } +} - @media (max-width: 480px) { - font-size: 10px; +// 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; } } } -// 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; - } - } +// 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 index 9ada5ec..9c89055 100644 --- a/frontend/src/app/components/inventory-summary/inventory-summary.component.ts +++ b/frontend/src/app/components/inventory-summary/inventory-summary.component.ts @@ -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(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 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); } -} +} \ No newline at end of file diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts index 417b361..88c59c3 100644 --- a/frontend/src/app/services/filament.service.ts +++ b/frontend/src/app/services/filament.service.ts @@ -1,176 +1,52 @@ -import { Injectable, signal, computed } from '@angular/core'; -import { Filament, classifyStockLevel } from '../models/filament.model'; +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'; /** - * 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. + * API base URL — matches the Extrudex backend. + * TODO: Move to environment config when environments are set up. */ -@Injectable({ - providedIn: 'root', -}) +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 { - /** 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: '', - }, - ]); + private readonly http = inject(HttpClient); - /** Public read-only view of all filaments */ - readonly filaments = this._filaments.asReadonly(); + /** Reactive filament data — components read from this signal */ + readonly filaments = signal([]); - /** - * 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; + /** 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; } - /** - * 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)); + /** Fetch a single filament by ID */ + getFilament(id: string): Observable { + return this.http.get(`${API_BASE_URL}/${id}`); } - /** Computed: count of active spools */ - readonly activeCount = computed(() => - this._filaments().filter((f) => f.isActive).length - ); + /** Set filament data directly — used by components or SignalR handlers */ + setFilaments(data: Filament[]): void { + this.filaments.set(data); + } - /** 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 - ); -} + /** Delete a filament spool by ID */ + deleteFilament(id: string): Observable { + return this.http.delete(`${API_BASE_URL}/${id}`); + } +} \ No newline at end of file