CUB-41: Add low stock indicators to filament UI
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<!-- Filament Inventory Table — with low stock indicators -->
|
||||
<div class="filament-table-container" role="region" aria-label="Filament inventory">
|
||||
|
||||
<!-- Low Stock Alert Banner — shown when critical or low stock spools exist -->
|
||||
@if (criticalCount() > 0) {
|
||||
<div class="alert-banner critical" role="alert">
|
||||
<mat-icon aria-hidden="true">error</mat-icon>
|
||||
<span>{{ criticalCount() }} spool{{ criticalCount() > 1 ? 's' : '' }} critically low (≤10% remaining)</span>
|
||||
</div>
|
||||
} @else if (lowStockCount() > 0) {
|
||||
<div class="alert-banner low" role="alert">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
<span>{{ lowStockCount() }} spool{{ lowStockCount() > 1 ? 's' : '' }} running low (≤25% remaining)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filament Table -->
|
||||
<table mat-table
|
||||
[dataSource]="sortedFilaments()"
|
||||
matSort
|
||||
(matSortChange)="sortData($event)"
|
||||
class="filament-table"
|
||||
aria-label="Filament inventory table">
|
||||
|
||||
<!-- Color Column -->
|
||||
<ng-container matColumnDef="color">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="color">Color</th>
|
||||
<td mat-cell *matCellDef="let filament">
|
||||
<span class="color-swatch"
|
||||
[style.background-color]="filament.colorHex"
|
||||
[matTooltip]="filament.colorName"
|
||||
matTooltipPosition="after"
|
||||
[attr.aria-label]="filament.colorName">
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Material Column -->
|
||||
<ng-container matColumnDef="material">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="material">Material</th>
|
||||
<td mat-cell *matCellDef="let filament">
|
||||
<span class="material-name">{{ filament.materialBaseName }}</span>
|
||||
@if (filament.materialModifierName) {
|
||||
<span class="material-modifier"> {{ filament.materialModifierName }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Brand Column -->
|
||||
<ng-container matColumnDef="brand">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="brand">Brand</th>
|
||||
<td mat-cell *matCellDef="let filament">{{ filament.brand }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Serial Column -->
|
||||
<ng-container matColumnDef="serial">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="serial">Serial</th>
|
||||
<td mat-cell *matCellDef="let filament" class="serial-cell">{{ filament.spoolSerial }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Remaining Weight Column -->
|
||||
<ng-container matColumnDef="remaining">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="remaining">Remaining</th>
|
||||
<td mat-cell *matCellDef="let filament">
|
||||
<div class="remaining-cell">
|
||||
<span class="remaining-text">
|
||||
{{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }}
|
||||
</span>
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getRemainingPercent(filament)"
|
||||
[ngClass]="classifyStockLevel(filament)"
|
||||
[matTooltip]="getRemainingPercent(filament).toFixed(0) + '% remaining'"
|
||||
matTooltipPosition="below">
|
||||
</mat-progress-bar>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Stock Level Indicator Column -->
|
||||
<ng-container matColumnDef="stockLevel">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="stockLevel">Stock</th>
|
||||
<td mat-cell *matCellDef="let filament">
|
||||
@let level = classifyStockLevel(filament);
|
||||
<mat-chip-set aria-label="Stock level">
|
||||
<mat-chip
|
||||
[ngClass]="level"
|
||||
[matTooltip]="stockLevelLabel(level) + ' — ' + getRemainingPercent(filament).toFixed(0) + '% remaining'"
|
||||
matTooltipPosition="below">
|
||||
<mat-icon matChipStart [ngClass]="level">{{ stockLevelIcon(level) }}</mat-icon>
|
||||
<span>{{ stockLevelLabel(level) }}</span>
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header="status">Status</th>
|
||||
<td mat-cell *matCellDef="let filament">
|
||||
<span class="status-badge"
|
||||
[class.active]="filament.isActive"
|
||||
[class.inactive]="!filament.isActive">
|
||||
{{ filament.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="columns()"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns();"
|
||||
[class.row-critical]="classifyStockLevel(row) === 'critical'"
|
||||
[class.row-low]="classifyStockLevel(row) === 'low'">
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Empty state -->
|
||||
@if (filaments().length === 0) {
|
||||
<div class="empty-state" role="status">
|
||||
<mat-icon aria-hidden="true">inventory_2</mat-icon>
|
||||
<p>No filament spools found</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Filament Table Component Styles
|
||||
* Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA
|
||||
* Low stock indicators use high-contrast colors for workshop visibility
|
||||
*/
|
||||
|
||||
// Touch-optimized sizing
|
||||
$touch-target-min: 48px;
|
||||
$spacing-unit: 8px;
|
||||
|
||||
// Stock level colors — high contrast, accessible
|
||||
$color-critical: #ef4444; // Red — critically low
|
||||
$color-low: #f59e0b; // Amber — running low
|
||||
$color-moderate: #3b82f6; // Blue — moderate
|
||||
$color-healthy: #22c55e; // Green — healthy/OK
|
||||
$color-active: #22c55e; // Green — active spool
|
||||
$color-inactive: #94a3b8; // Gray — inactive spool
|
||||
|
||||
.filament-table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// Alert banner for low stock warnings
|
||||
.alert-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-unit;
|
||||
padding: $spacing-unit * 1.5 $spacing-unit * 2;
|
||||
border-radius: 8px;
|
||||
margin-bottom: $spacing-unit * 2;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
&.critical {
|
||||
background-color: rgba($color-critical, 0.12);
|
||||
color: $color-critical;
|
||||
border: 1px solid rgba($color-critical, 0.3);
|
||||
}
|
||||
|
||||
&.low {
|
||||
background-color: rgba($color-low, 0.12);
|
||||
color: $color-low;
|
||||
border: 1px solid rgba($color-low, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Table styling
|
||||
.filament-table {
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 14px;
|
||||
padding: 12px 16px !important;
|
||||
min-height: $touch-target-min;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// Row highlight for low stock
|
||||
.mat-mdc-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
&.row-critical {
|
||||
background-color: rgba($color-critical, 0.06) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($color-critical, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.row-low {
|
||||
background-color: rgba($color-low, 0.06) !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($color-low, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Color swatch
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0, 0, 0, 0.12);
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
// Material name
|
||||
.material-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.material-modifier {
|
||||
font-size: 12px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
// Serial cell — monospace
|
||||
.serial-cell {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// Remaining weight cell
|
||||
.remaining-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 120px;
|
||||
|
||||
.remaining-text {
|
||||
font-size: 13px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar stock level variants
|
||||
mat-progress-bar {
|
||||
&.critical {
|
||||
--mat-progress-bar-active-indicator-color: #{$color-critical};
|
||||
}
|
||||
|
||||
&.low {
|
||||
--mat-progress-bar-active-indicator-color: #{$color-low};
|
||||
}
|
||||
|
||||
&.moderate {
|
||||
--mat-progress-bar-active-indicator-color: #{$color-moderate};
|
||||
}
|
||||
|
||||
&.healthy {
|
||||
--mat-progress-bar-active-indicator-color: #{$color-healthy};
|
||||
}
|
||||
}
|
||||
|
||||
// Stock level chip variants
|
||||
mat-chip {
|
||||
min-height: 32px !important;
|
||||
font-size: 12px !important;
|
||||
|
||||
&.critical {
|
||||
background-color: rgba($color-critical, 0.15) !important;
|
||||
color: $color-critical;
|
||||
|
||||
mat-icon {
|
||||
color: $color-critical;
|
||||
}
|
||||
}
|
||||
|
||||
&.low {
|
||||
background-color: rgba($color-low, 0.15) !important;
|
||||
color: $color-low;
|
||||
|
||||
mat-icon {
|
||||
color: $color-low;
|
||||
}
|
||||
}
|
||||
|
||||
&.moderate {
|
||||
background-color: rgba($color-moderate, 0.1) !important;
|
||||
color: $color-moderate;
|
||||
|
||||
mat-icon {
|
||||
color: $color-moderate;
|
||||
}
|
||||
}
|
||||
|
||||
&.healthy {
|
||||
background-color: rgba($color-healthy, 0.1) !important;
|
||||
color: $color-healthy;
|
||||
|
||||
mat-icon {
|
||||
color: $color-healthy;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&.active {
|
||||
background-color: rgba($color-active, 0.12);
|
||||
color: $color-active;
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
background-color: rgba($color-inactive, 0.12);
|
||||
color: $color-inactive;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px $spacing-unit * 2;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px !important;
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
opacity: 0.4;
|
||||
margin-bottom: $spacing-unit * 2;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Filament,
|
||||
StockLevel,
|
||||
getRemainingPercent,
|
||||
classifyStockLevel,
|
||||
} from '../../models/filament.model';
|
||||
|
||||
/** Create a test filament with defaults — override specific fields */
|
||||
function createFilament(overrides: Partial<Filament> = {}): Filament {
|
||||
return {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
materialBaseId: '10000000-0000-0000-0000-000000000001',
|
||||
materialBaseName: 'PLA',
|
||||
materialFinishId: '20000000-0000-0000-0000-000000000001',
|
||||
materialFinishName: 'Basic',
|
||||
materialModifierId: null,
|
||||
materialModifierName: null,
|
||||
brand: 'Bambu Lab',
|
||||
colorName: 'White',
|
||||
colorHex: '#FFFFFF',
|
||||
weightTotalGrams: 1000,
|
||||
weightRemainingGrams: 750,
|
||||
filamentDiameterMm: 1.75,
|
||||
spoolSerial: 'SN-001',
|
||||
purchasePrice: null,
|
||||
purchaseDate: null,
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
qrCodeUrl: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getRemainingPercent', () => {
|
||||
it('should return correct percentage', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 250 });
|
||||
expect(getRemainingPercent(filament)).toBe(25);
|
||||
});
|
||||
|
||||
it('should return 0 when total weight is 0', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 0, weightRemainingGrams: 0 });
|
||||
expect(getRemainingPercent(filament)).toBe(0);
|
||||
});
|
||||
|
||||
it('should cap at 100%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 100, weightRemainingGrams: 200 });
|
||||
expect(getRemainingPercent(filament)).toBe(100);
|
||||
});
|
||||
|
||||
it('should floor at 0%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 100, weightRemainingGrams: -10 });
|
||||
expect(getRemainingPercent(filament)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyStockLevel', () => {
|
||||
it('should classify as critical when ≤10%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 50 });
|
||||
expect(classifyStockLevel(filament)).toBe('critical');
|
||||
});
|
||||
|
||||
it('should classify as critical at exactly 10%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 100 });
|
||||
expect(classifyStockLevel(filament)).toBe('critical');
|
||||
});
|
||||
|
||||
it('should classify as low when ≤25%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 200 });
|
||||
expect(classifyStockLevel(filament)).toBe('low');
|
||||
});
|
||||
|
||||
it('should classify as moderate when ≤50%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 400 });
|
||||
expect(classifyStockLevel(filament)).toBe('moderate');
|
||||
});
|
||||
|
||||
it('should classify as healthy when >50%', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 750 });
|
||||
expect(classifyStockLevel(filament)).toBe('healthy');
|
||||
});
|
||||
|
||||
it('should classify 0 grams remaining as critical', () => {
|
||||
const filament = createFilament({ weightTotalGrams: 1000, weightRemainingGrams: 0 });
|
||||
expect(classifyStockLevel(filament)).toBe('critical');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import {
|
||||
Filament,
|
||||
StockLevel,
|
||||
getRemainingPercent,
|
||||
classifyStockLevel,
|
||||
} from '../../models/filament.model';
|
||||
|
||||
/** Display column definitions for the filament table */
|
||||
export type FilamentColumn =
|
||||
| 'color'
|
||||
| 'material'
|
||||
| 'brand'
|
||||
| 'serial'
|
||||
| 'remaining'
|
||||
| 'stockLevel'
|
||||
| 'status';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filament-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltipModule,
|
||||
MatSortModule,
|
||||
],
|
||||
templateUrl: './filament-table.component.html',
|
||||
styleUrl: './filament-table.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilamentTableComponent {
|
||||
/** Filament data input — reactive signal for live updates */
|
||||
readonly filaments = signal<Filament[]>([]);
|
||||
|
||||
/** Columns to display — defaults to all columns */
|
||||
@Input()
|
||||
set displayedColumns(cols: FilamentColumn[]) {
|
||||
this._displayedColumns.set(cols);
|
||||
}
|
||||
get displayedColumns(): FilamentColumn[] {
|
||||
return this._displayedColumns();
|
||||
}
|
||||
private readonly _displayedColumns = signal<FilamentColumn[]>([
|
||||
'color',
|
||||
'material',
|
||||
'brand',
|
||||
'serial',
|
||||
'remaining',
|
||||
'stockLevel',
|
||||
'status',
|
||||
]);
|
||||
|
||||
/** Default columns for template binding */
|
||||
readonly columns = this._displayedColumns;
|
||||
|
||||
/** Sorted filament data */
|
||||
readonly sortedFilaments = signal<Filament[]>([]);
|
||||
|
||||
/** Computed: count of low/critical spools */
|
||||
readonly lowStockCount = computed(() =>
|
||||
this.filaments().filter(
|
||||
(f) => classifyStockLevel(f) === 'low' || classifyStockLevel(f) === 'critical'
|
||||
).length
|
||||
);
|
||||
|
||||
/** Computed: count of critical spools */
|
||||
readonly criticalCount = computed(() =>
|
||||
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: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** Update filament data — called by parent or service */
|
||||
updateFilaments(data: Filament[]): void {
|
||||
this.filaments.set(data);
|
||||
this.sortedFilaments.set([...data]);
|
||||
}
|
||||
|
||||
/** Handle sort changes from MatSort */
|
||||
sortData(sort: Sort): void {
|
||||
const data = [...this.filaments()];
|
||||
if (!sort.active || sort.direction === '') {
|
||||
this.sortedFilaments.set(data);
|
||||
return;
|
||||
}
|
||||
const sorted = data.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active as FilamentColumn) {
|
||||
case 'material':
|
||||
return compare(a.materialBaseName, b.materialBaseName, isAsc);
|
||||
case 'brand':
|
||||
return compare(a.brand, b.brand, isAsc);
|
||||
case 'serial':
|
||||
return compare(a.spoolSerial, b.spoolSerial, isAsc);
|
||||
case 'remaining':
|
||||
return compare(
|
||||
getRemainingPercent(a),
|
||||
getRemainingPercent(b),
|
||||
isAsc
|
||||
);
|
||||
case 'stockLevel':
|
||||
return compare(
|
||||
stockLevelOrder(classifyStockLevel(a)),
|
||||
stockLevelOrder(classifyStockLevel(b)),
|
||||
isAsc
|
||||
);
|
||||
case 'status':
|
||||
return compare(
|
||||
a.isActive ? 0 : 1,
|
||||
b.isActive ? 0 : 1,
|
||||
isAsc
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
this.sortedFilaments.set(sorted);
|
||||
}
|
||||
|
||||
/** Template helper: get remaining percent */
|
||||
getRemainingPercent = getRemainingPercent;
|
||||
|
||||
/** Template helper: classify stock level */
|
||||
classifyStockLevel = classifyStockLevel;
|
||||
|
||||
/** Template helper: stock level icon */
|
||||
stockLevelIcon(level: StockLevel): string {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return 'error';
|
||||
case 'low':
|
||||
return 'warning';
|
||||
case 'moderate':
|
||||
return 'info';
|
||||
case 'healthy':
|
||||
return 'check_circle';
|
||||
}
|
||||
}
|
||||
|
||||
/** Template helper: stock level label */
|
||||
stockLevelLabel(level: StockLevel): string {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return 'Critical';
|
||||
case 'low':
|
||||
return 'Low';
|
||||
case 'moderate':
|
||||
return 'Moderate';
|
||||
case 'healthy':
|
||||
return 'Healthy';
|
||||
}
|
||||
}
|
||||
|
||||
/** Template helper: format remaining weight */
|
||||
formatWeight(grams: number): string {
|
||||
if (grams >= 1000) {
|
||||
return `${(grams / 1000).toFixed(1)}kg`;
|
||||
}
|
||||
return `${Math.round(grams)}g`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare helper for sorting */
|
||||
function compare(a: number | string, b: number | string, isAsc: boolean): number {
|
||||
return (a < b ? -1 : a > b ? 1 : 0) * (isAsc ? 1 : -1);
|
||||
}
|
||||
|
||||
/** Stock level sort order (critical=0, healthy=3) */
|
||||
function stockLevelOrder(level: StockLevel): number {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return 0;
|
||||
case 'low':
|
||||
return 1;
|
||||
case 'moderate':
|
||||
return 2;
|
||||
case 'healthy':
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user