diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b31e340..d0614db 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "@angular/animations": "^21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
@@ -326,6 +327,21 @@
"yarn": ">= 1.13.0"
}
},
+ "node_modules/@angular/animations": {
+ "version": "21.2.10",
+ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.10.tgz",
+ "integrity": "sha512-sIzAcxwtRCJ/fu0tK4mo1ooiEaDxJ+Nl6s9nK1D1NP1em12VX03Jx8CMixp/kVtgh4mZnm1x6psBB0FUz3U3Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "21.2.10"
+ }
+ },
"node_modules/@angular/build": {
"version": "21.2.8",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1e2cedb..02eaba4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
"private": true,
"packageManager": "npm@11.11.0",
"dependencies": {
+ "@angular/animations": "^21.2.10",
"@angular/cdk": "^21.2.8",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts
index cb1270e..e66aad8 100644
--- a/frontend/src/app/app.config.ts
+++ b/frontend/src/app/app.config.ts
@@ -1,11 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
+import { provideHttpClient, withFetch } from '@angular/common/http';
+import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
- provideRouter(routes)
+ provideRouter(routes),
+ provideHttpClient(withFetch()),
+ provideAnimationsAsync(),
]
-};
+};
\ No newline at end of file
diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html
new file mode 100644
index 0000000..71fe1c6
--- /dev/null
+++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html
@@ -0,0 +1,78 @@
+
+
+ warning
+ Delete Filament Spool?
+
+
+
+
+ You are about to permanently remove this filament spool from inventory.
+
+
+
+
+
+ Material
+ {{ filament.materialBaseName }}{{ filament.materialFinishName ? ' — ' + filament.materialFinishName : '' }}{{ filament.materialModifierName ? ' (' + filament.materialModifierName + ')' : '' }}
+
+
+
+ Brand
+ {{ filament.brand }}
+
+
+
+ Color
+
+
+
+ {{ filament.colorName }}
+
+
+
+
+ Serial
+ {{ filament.spoolSerial }}
+
+
+
+ Remaining
+ {{ formatWeight(filament.weightRemainingGrams) }} / {{ formatWeight(filament.weightTotalGrams) }}
+
+
+
+ Status
+
+
+ {{ filament.isActive ? 'Active' : 'Inactive' }}
+
+
+
+
+
+
+ info
+ This action cannot be undone.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss
new file mode 100644
index 0000000..6789bf4
--- /dev/null
+++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss
@@ -0,0 +1,150 @@
+/**
+ * Delete Filament Dialog Styles
+ * Touch-optimized confirmation dialog for spool removal
+ */
+
+$spacing-unit: 8px;
+$color-critical: #ef4444;
+$color-inactive: #94a3b8;
+$color-active: #22c55e;
+
+// Dialog title
+h2[mat-dialog-title] {
+ display: flex;
+ align-items: center;
+ gap: $spacing-unit;
+ color: $color-critical;
+
+ mat-icon {
+ font-size: 24px !important;
+ width: 24px !important;
+ height: 24px !important;
+ }
+}
+
+// Description text
+.dialog-description {
+ margin: 0 0 $spacing-unit * 2;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--mat-sys-on-surface);
+}
+
+// Spool details card
+.spool-details {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-unit;
+ padding: $spacing-unit * 1.5;
+ background-color: var(--mat-sys-surface-container);
+ border-radius: 8px;
+ margin-bottom: $spacing-unit * 2;
+}
+
+.detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: $spacing-unit * 0.5 0;
+ font-size: 14px;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--mat-sys-outline-variant);
+ padding-bottom: $spacing-unit;
+ }
+}
+
+.detail-label {
+ font-weight: 500;
+ color: var(--mat-sys-on-surface-variant);
+ flex-shrink: 0;
+}
+
+.detail-value {
+ font-weight: 400;
+ color: var(--mat-sys-on-surface);
+ text-align: right;
+}
+
+// Color swatch inline
+.color-swatch-inline {
+ display: inline-block;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 1.5px solid rgba(0, 0, 0, 0.12);
+ vertical-align: middle;
+ margin-right: 4px;
+}
+
+.color-value {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+// Serial value — monospace
+.serial-value {
+ font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
+ font-size: 13px;
+ letter-spacing: 0.02em;
+}
+
+// Status badge — matches filament table styling
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ 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;
+ }
+}
+
+// Warning text
+.dialog-warning {
+ display: flex;
+ align-items: center;
+ gap: $spacing-unit;
+ margin: 0;
+ font-size: 13px;
+ color: $color-critical;
+
+ mat-icon {
+ font-size: 18px !important;
+ width: 18px !important;
+ height: 18px !important;
+ }
+}
+
+// Dialog action buttons
+mat-dialog-actions {
+ padding-top: $spacing-unit * 2;
+
+ .cancel-button {
+ min-width: 80px;
+ }
+
+ .confirm-button {
+ min-width: 120px;
+
+ mat-icon {
+ font-size: 18px !important;
+ width: 18px !important;
+ height: 18px !important;
+ margin-right: 4px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts
new file mode 100644
index 0000000..d16a87a
--- /dev/null
+++ b/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts
@@ -0,0 +1,68 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogRef,
+ MatDialogModule,
+} from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatChipsModule } from '@angular/material/chips';
+
+import { Filament } from '../../models/filament.model';
+
+/**
+ * Data passed into the delete confirmation dialog.
+ */
+export interface DeleteFilamentDialogData {
+ filament: Filament;
+}
+
+/**
+ * Delete confirmation dialog for filament spool removal.
+ *
+ * Displays spool details (material, brand, color, serial, remaining weight)
+ * and requires the user to confirm before deletion proceeds.
+ * Cancel dismisses the dialog with no action.
+ */
+@Component({
+ selector: 'app-delete-filament-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatIconModule,
+ MatChipsModule,
+ ],
+ templateUrl: './delete-filament-dialog.component.html',
+ styleUrl: './delete-filament-dialog.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DeleteFilamentDialogComponent {
+ private readonly dialogRef = inject(
+ MatDialogRef
+ );
+ readonly data: DeleteFilamentDialogData = inject(MAT_DIALOG_DATA);
+
+ /** The filament spool being considered for deletion */
+ readonly filament = this.data.filament;
+
+ /** Format weight for display in dialog */
+ formatWeight(grams: number): string {
+ if (grams >= 1000) {
+ return `${(grams / 1000).toFixed(1)}kg`;
+ }
+ return `${Math.round(grams)}g`;
+ }
+
+ /** Cancel — close dialog with false (no deletion) */
+ onCancel(): void {
+ this.dialogRef.close(false);
+ }
+
+ /** Confirm — close dialog with true (proceed with deletion) */
+ onConfirm(): void {
+ this.dialogRef.close(true);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/components/filament-table/filament-table.component.html b/frontend/src/app/components/filament-table/filament-table.component.html
index 75d798f..e0aa433 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.html
+++ b/frontend/src/app/components/filament-table/filament-table.component.html
@@ -1,4 +1,4 @@
-
+
@@ -106,10 +106,32 @@
+
+
+ | Actions |
+
+
+ |
+
+
+ [class.row-low]="classifyStockLevel(row) === 'low'"
+ [class.row-deleting]="deleting() === row.id">
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 47025ac..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,12 +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 { 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 =
@@ -27,7 +37,8 @@ export type FilamentColumn =
| 'serial'
| 'remaining'
| 'stockLevel'
- | 'status';
+ | 'status'
+ | 'actions';
@Component({
selector: 'app-filament-table',
@@ -40,16 +51,26 @@ export type FilamentColumn =
MatProgressBarModule,
MatTooltipModule,
MatSortModule,
+ 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);
@@ -65,6 +86,7 @@ export class FilamentTableComponent {
'remaining',
'stockLevel',
'status',
+ 'actions',
]);
/** Default columns for template binding */
@@ -252,6 +274,52 @@ export class FilamentTableComponent {
this.sortedFilaments.set(sorted);
}
+ /**
+ * 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,
+ });
+
+ dialogRef.afterClosed().subscribe((confirmed: boolean | undefined) => {
+ if (!confirmed) {
+ return; // User cancelled — no action
+ }
+
+ // Mark as deleting for UI feedback
+ this.deleting.set(filament.id);
+
+ 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 */
getRemainingPercent = getRemainingPercent;
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