diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d0614db..b31e340 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,7 +8,6 @@
"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",
@@ -327,21 +326,6 @@
"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 02eaba4..1e2cedb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,7 +11,6 @@
"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 e66aad8..b1fd09d 100644
--- a/frontend/src/app/app.config.ts
+++ b/frontend/src/app/app.config.ts
@@ -1,6 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
-import { provideHttpClient, withFetch } from '@angular/common/http';
+import { provideHttpClient } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
@@ -9,7 +9,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
- provideHttpClient(withFetch()),
+ provideHttpClient(),
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
deleted file mode 100644
index 71fe1c6..0000000
--- a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.html
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
- 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
deleted file mode 100644
index 6789bf4..0000000
--- a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.scss
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * 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
deleted file mode 100644
index d16a87a..0000000
--- a/frontend/src/app/components/delete-filament-dialog/delete-filament-dialog.component.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-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 e0aa433..b7929f2 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.html
+++ b/frontend/src/app/components/filament-table/filament-table.component.html
@@ -1,6 +1,16 @@
-
+
+
+
+
@if (criticalCount() > 0) {
@@ -106,23 +116,16 @@
-
+
- Actions |
-
+ | |
+
|
@@ -130,8 +133,7 @@
+ [class.row-low]="classifyStockLevel(row) === 'low'">
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 fad3fef..cbbbfe5 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.scss
+++ b/frontend/src/app/components/filament-table/filament-table.component.scss
@@ -264,20 +264,6 @@ 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 9a0c590..a9f427c 100644
--- a/frontend/src/app/components/filament-table/filament-table.component.ts
+++ b/frontend/src/app/components/filament-table/filament-table.component.ts
@@ -15,19 +15,16 @@ 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';
+ FilamentDialogComponent,
+ FilamentDialogData,
+} from '../filament-dialog/filament-dialog.component';
/** Display column definitions for the filament table */
export type FilamentColumn =
@@ -53,7 +50,6 @@ export type FilamentColumn =
MatSortModule,
MatButtonModule,
MatDialogModule,
- MatSnackBarModule,
],
templateUrl: './filament-table.component.html',
styleUrl: './filament-table.component.scss',
@@ -61,16 +57,11 @@ export type FilamentColumn =
})
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
([]);
- /** Whether a delete operation is in progress */
- readonly deleting = signal(null);
-
- /** Columns to display — defaults to all columns including actions */
+ /** Columns to display — defaults to all columns */
@Input()
set displayedColumns(cols: FilamentColumn[]) {
this._displayedColumns.set(cols);
@@ -274,52 +265,6 @@ 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;
@@ -361,6 +306,47 @@ export class FilamentTableComponent {
}
return `${Math.round(grams)}g`;
}
+
+ /** Open the add filament dialog. */
+ openAddDialog(): void {
+ const data: FilamentDialogData = {};
+ const ref = this.dialog.open(FilamentDialogComponent, {
+ width: '600px',
+ maxWidth: '95vw',
+ data,
+ autoFocus: 'first-typable',
+ });
+
+ ref.afterClosed().subscribe((result: boolean) => {
+ if (result) {
+ this.onFilamentSaved();
+ }
+ });
+ }
+
+ /** Open the edit filament dialog for a specific spool. */
+ openEditDialog(filament: Filament): void {
+ const data: FilamentDialogData = { filament };
+ const ref = this.dialog.open(FilamentDialogComponent, {
+ width: '600px',
+ maxWidth: '95vw',
+ data,
+ autoFocus: 'first-typable',
+ });
+
+ ref.afterClosed().subscribe((result: boolean) => {
+ if (result) {
+ this.onFilamentSaved();
+ }
+ });
+ }
+
+ /** Called after a successful save — reload filament data. */
+ protected onFilamentSaved(): void {
+ // TODO: Replace with FilamentService.refresh() call when SignalR integration is ready.
+ // For now, this is the hook for refreshing data after a save.
+ // Consumers can override or listen to signal changes.
+ }
}
/** Compare helper for sorting */
diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts
index 01c4755..130c234 100644
--- a/frontend/src/app/services/filament.service.ts
+++ b/frontend/src/app/services/filament.service.ts
@@ -2,36 +2,133 @@ import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
+import { environment } from '../../environments/environment';
import { Filament } from '../models/filament.model';
+import { PagedResponse } from '../models/paged-response.model';
+import {
+ MaterialBase,
+ MaterialFinish,
+ MaterialModifier,
+} from '../models/material.model';
/**
- * API base URL — matches the Extrudex backend default.
- * TODO: Move to environment config when multi-environment support is added.
+ * Request body for creating a new filament spool.
+ * Matches the backend CreateFilamentRequest DTO.
*/
-const API_BASE_URL = '/api';
+export interface CreateFilamentRequest {
+ materialBaseId: string;
+ materialFinishId: string;
+ materialModifierId: string | null;
+ brand: string;
+ colorName: string;
+ colorHex: string;
+ weightTotalGrams: number;
+ weightRemainingGrams: number;
+ filamentDiameterMm: number;
+ spoolSerial: string;
+ purchasePrice: number | null;
+ purchaseDate: string | null;
+ isActive: boolean;
+}
/**
- * Service for CRUD operations on filament spools.
- * Communicates with the Extrudex backend SpoolsController.
+ * Request body for updating an existing filament spool.
+ * Matches the backend UpdateFilamentRequest DTO.
+ */
+export interface UpdateFilamentRequest {
+ materialBaseId: string;
+ materialFinishId: string;
+ materialModifierId: string | null;
+ brand: string;
+ colorName: string;
+ colorHex: string;
+ weightTotalGrams: number;
+ weightRemainingGrams: number;
+ filamentDiameterMm: number;
+ spoolSerial: string;
+ purchasePrice: number | null;
+ purchaseDate: string | null;
+ isActive: boolean;
+}
+
+/**
+ * Service for interacting with the Extrudex filament and material APIs.
+ * Handles CRUD for filament spools and material lookup data.
*/
@Injectable({ providedIn: 'root' })
export class FilamentService {
private readonly http = inject(HttpClient);
+ private readonly baseUrl = environment.apiBaseUrl;
+
+ // ── Filament CRUD ────────────────────────────────────────
/**
- * Fetch all filament spools from the backend.
- * GET /api/spools
+ * Get a paginated list of filament spools.
*/
- getFilaments(): Observable {
- return this.http.get(`${API_BASE_URL}/spools`);
+ getFilaments(params?: {
+ pageNumber?: number;
+ pageSize?: number;
+ materialBaseId?: string;
+ materialFinishId?: string;
+ materialModifierId?: string;
+ brand?: string;
+ isActive?: boolean;
+ }): Observable> {
+ return this.http.get>(
+ `${this.baseUrl}/api/filaments`,
+ { params: params as Record }
+ );
}
/**
- * Soft-delete a filament spool by ID.
- * DELETE /api/spools/{id}
- * Returns 204 No Content on success.
+ * Get a single filament spool by ID.
*/
- deleteFilament(id: string): Observable {
- return this.http.delete(`${API_BASE_URL}/spools/${id}`);
+ getFilament(id: string): Observable {
+ return this.http.get(`${this.baseUrl}/api/filaments/${id}`);
+ }
+
+ /**
+ * Create a new filament spool.
+ */
+ createFilament(request: CreateFilamentRequest): Observable {
+ return this.http.post(`${this.baseUrl}/api/filaments`, request);
+ }
+
+ /**
+ * Update an existing filament spool.
+ */
+ updateFilament(id: string, request: UpdateFilamentRequest): Observable {
+ return this.http.put(`${this.baseUrl}/api/filaments/${id}`, request);
+ }
+
+ // ── Material Lookups ─────────────────────────────────────
+
+ /**
+ * Get all material bases (PLA, PETG, ABS, etc.).
+ */
+ getMaterialBases(): Observable {
+ return this.http.get(`${this.baseUrl}/api/materials/bases`);
+ }
+
+ /**
+ * Get all material finishes, optionally filtered by material base.
+ */
+ getMaterialFinishes(materialBaseId?: string): Observable {
+ const params: Record = {};
+ if (materialBaseId) {
+ params['materialBaseId'] = materialBaseId;
+ }
+ return this.http.get(`${this.baseUrl}/api/materials/finishes`, { params });
+ }
+
+ /**
+ * Get all material modifiers, optionally filtered by material base.
+ */
+ getMaterialModifiers(materialBaseId?: string): Observable {
+ const params: Record = {};
+ if (materialBaseId) {
+ params['materialBaseId'] = materialBaseId;
+ }
+ return this.http.get(`${this.baseUrl}/api/materials/modifiers`, { params });
}
}
\ No newline at end of file