Merge pull request 'CUB-43: Add inventory dashboard summary' (#23) from agent/rex/CUB-43-inventory-dashboard-summary into dev
All checks were successful
Dev Build / build-test (push) Successful in 2m38s
All checks were successful
Dev Build / build-test (push) Successful in 2m38s
This commit was merged in pull request #23.
This commit is contained in:
@@ -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(),
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 — 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>
|
||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!-- Inventory Dashboard Summary — filament metrics at a glance -->
|
||||||
|
<section class="inventory-summary" role="region" aria-label="Inventory summary">
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="summary-loading" role="status" aria-live="polite">
|
||||||
|
<mat-icon aria-hidden="true" class="spin">sync</mat-icon>
|
||||||
|
<span>Loading inventory...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
@else if (error()) {
|
||||||
|
<div class="summary-error" role="alert" aria-live="assertive">
|
||||||
|
<mat-icon aria-hidden="true">error_outline</mat-icon>
|
||||||
|
<span>{{ error() }}</span>
|
||||||
|
<button class="retry-btn" (click)="refresh()" aria-label="Retry loading inventory">
|
||||||
|
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Loaded State -->
|
||||||
|
@else {
|
||||||
|
<!-- Health Status Indicator -->
|
||||||
|
<div class="summary-item health-status"
|
||||||
|
[class]="healthClass()"
|
||||||
|
[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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
frontend/src/app/services/filament.service.ts
Normal file
52
frontend/src/app/services/filament.service.ts
Normal file
@@ -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<Filament[]>([]);
|
||||||
|
|
||||||
|
/** Fetch all filament spools and update the signal */
|
||||||
|
getFilaments(): Observable<Filament[]> {
|
||||||
|
const req = this.http.get<Filament[]>(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<Filament> {
|
||||||
|
return this.http.get<Filament>(`${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<void> {
|
||||||
|
return this.http.delete<void>(`${API_BASE_URL}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user