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 @@ + +

+ + 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' }} + + +
+
+ +

+ + 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