@@ -22,7 +16,7 @@
+
+
+ | Actions |
+
+
+ |
+
+
+ [class.row-low]="classifyStockLevel(row) === 'low'"
+ [class.row-deleting]="deleting() === row.id">
-
- @if (filteredFilaments().length === 0 && filaments().length > 0) {
-
-
filter_alt_off
-
No filaments match the current filters
-
- }
-
-
+
@if (filaments().length === 0) {
inventory_2
diff --git a/frontend/src/app/components/filament-table/filament-table.component.scss b/frontend/src/app/components/filament-table/filament-table.component.scss
index d85bb4a..c1a4f1a 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.scss
+++ b/frontend/src/app/components/filament-table/filament-table.component.scss
@@ -235,6 +235,20 @@ mat-chip {
}
}
+// Actions column
+.actions-cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+// Row being deleted — subtle fade
+:host ::ng-deep .row-deleting {
+ opacity: 0.5;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+}
+
// Empty state
.empty-state {
display: flex;
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..9a0c590 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.ts
+++ b/frontend/src/app/components/filament-table/filament-table.component.ts
@@ -3,6 +3,7 @@ import {
Component,
Input,
computed,
+ inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -12,13 +13,21 @@ 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 { FilamentFilterComponent, FilamentFilterState } from '../filament-filter/filament-filter.component';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+
import {
Filament,
StockLevel,
getRemainingPercent,
classifyStockLevel,
} from '../../models/filament.model';
+import { FilamentService } from '../../services/filament.service';
+import {
+ DeleteFilamentDialogComponent,
+ DeleteFilamentDialogData,
+} from '../delete-filament-dialog/delete-filament-dialog.component';
/** Display column definitions for the filament table */
export type FilamentColumn =
@@ -28,7 +37,8 @@ export type FilamentColumn =
| 'serial'
| 'remaining'
| 'stockLevel'
- | 'status';
+ | 'status'
+ | 'actions';
@Component({
selector: 'app-filament-table',
@@ -41,17 +51,26 @@ export type FilamentColumn =
MatProgressBarModule,
MatTooltipModule,
MatSortModule,
- FilamentFilterComponent,
+ MatButtonModule,
+ MatDialogModule,
+ MatSnackBarModule,
],
templateUrl: './filament-table.component.html',
styleUrl: './filament-table.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilamentTableComponent {
+ private readonly dialog = inject(MatDialog);
+ private readonly snackBar = inject(MatSnackBar);
+ private readonly filamentService = inject(FilamentService);
+
/** Filament data input — reactive signal for live updates */
readonly filaments = signal([]);
- /** Columns to display — defaults to all columns */
+ /** Whether a delete operation is in progress */
+ readonly deleting = signal(null);
+
+ /** Columns to display — defaults to all columns including actions */
@Input()
set displayedColumns(cols: FilamentColumn[]) {
this._displayedColumns.set(cols);
@@ -67,29 +86,15 @@ export class FilamentTableComponent {
'remaining',
'stockLevel',
'status',
+ 'actions',
]);
/** Default columns for template binding */
readonly columns = this._displayedColumns;
- /** Current filter state */
- readonly filterState = signal({
- materialBaseNames: [],
- colorSearch: '',
- lowStockOnly: false,
- activeOnly: false,
- });
-
/** Sorted filament data */
readonly sortedFilaments = signal([]);
- /** Computed: filtered + sorted filament data for display */
- readonly filteredFilaments = computed(() => {
- const data = this.sortedFilaments();
- const filters = this.filterState();
- return data.filter((f) => this.matchesFilter(f, filters));
- });
-
/** Computed: count of low/critical spools */
readonly lowStockCount = computed(() =>
this.filaments().filter(
@@ -228,9 +233,6 @@ export class FilamentTableComponent {
this.sortedFilaments.set([...data]);
}
- /** All filament data — for the filter component to derive material options */
- readonly allFilaments = this.filaments;
-
/** Handle sort changes from MatSort */
sortData(sort: Sort): void {
const data = [...this.filaments()];
@@ -272,44 +274,50 @@ export class FilamentTableComponent {
this.sortedFilaments.set(sorted);
}
- /** Handle filter changes from FilamentFilterComponent */
- onFilterChange(state: FilamentFilterState): void {
- this.filterState.set(state);
- }
+ /**
+ * Open the delete confirmation dialog for a filament spool.
+ * On confirm: calls DELETE endpoint and removes the row on success.
+ * On cancel: dialog dismissed, no action taken.
+ */
+ onDeleteClick(filament: Filament): void {
+ const dialogData: DeleteFilamentDialogData = { filament };
+ const dialogRef = this.dialog.open(DeleteFilamentDialogComponent, {
+ data: dialogData,
+ width: '480px',
+ disableClose: true,
+ });
- /** Check if a filament matches the current filter state */
- private matchesFilter(filament: Filament, filters: FilamentFilterState): boolean {
- // Material filter — empty means all
- if (
- filters.materialBaseNames.length > 0 &&
- !filters.materialBaseNames.includes(filament.materialBaseName)
- ) {
- return false;
- }
-
- // Color search — empty means all
- if (
- filters.colorSearch &&
- !filament.colorName.toLowerCase().includes(filters.colorSearch) &&
- !filament.colorHex.toLowerCase().includes(filters.colorSearch)
- ) {
- return false;
- }
-
- // Low stock filter — show only critical/low
- if (filters.lowStockOnly) {
- const level = classifyStockLevel(filament);
- if (level !== 'critical' && level !== 'low') {
- return false;
+ dialogRef.afterClosed().subscribe((confirmed: boolean | undefined) => {
+ if (!confirmed) {
+ return; // User cancelled — no action
}
- }
- // Active only filter
- if (filters.activeOnly && !filament.isActive) {
- return false;
- }
+ // Mark as deleting for UI feedback
+ this.deleting.set(filament.id);
- return true;
+ this.filamentService.deleteFilament(filament.id).subscribe({
+ next: () => {
+ // Remove the deleted filament from local data
+ const updated = this.filaments().filter((f) => f.id !== filament.id);
+ this.updateFilaments(updated);
+ this.deleting.set(null);
+
+ this.snackBar.open(
+ `Deleted ${filament.materialBaseName} — ${filament.colorName}`,
+ 'Dismiss',
+ { duration: 4000 }
+ );
+ },
+ error: () => {
+ this.deleting.set(null);
+ this.snackBar.open(
+ `Failed to delete ${filament.materialBaseName} — ${filament.colorName}. Please try again.`,
+ 'Dismiss',
+ { duration: 6000 }
+ );
+ },
+ });
+ });
}
/** Template helper: get remaining percent */
diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts
new file mode 100644
index 0000000..01c4755
--- /dev/null
+++ b/frontend/src/app/services/filament.service.ts
@@ -0,0 +1,37 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+import { Filament } from '../models/filament.model';
+
+/**
+ * API base URL — matches the Extrudex backend default.
+ * TODO: Move to environment config when multi-environment support is added.
+ */
+const API_BASE_URL = '/api';
+
+/**
+ * Service for CRUD operations on filament spools.
+ * Communicates with the Extrudex backend SpoolsController.
+ */
+@Injectable({ providedIn: 'root' })
+export class FilamentService {
+ private readonly http = inject(HttpClient);
+
+ /**
+ * Fetch all filament spools from the backend.
+ * GET /api/spools
+ */
+ getFilaments(): Observable {
+ return this.http.get(`${API_BASE_URL}/spools`);
+ }
+
+ /**
+ * Soft-delete a filament spool by ID.
+ * DELETE /api/spools/{id}
+ * Returns 204 No Content on success.
+ */
+ deleteFilament(id: string): Observable {
+ return this.http.delete(`${API_BASE_URL}/spools/${id}`);
+ }
+}
\ No newline at end of file