diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index cb1270e..b1fd09d 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 } 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(), + provideAnimationsAsync(), ] -}; +}; \ No newline at end of file diff --git a/frontend/src/app/components/filament-dialog/filament-dialog.component.html b/frontend/src/app/components/filament-dialog/filament-dialog.component.html new file mode 100644 index 0000000..ed6da93 --- /dev/null +++ b/frontend/src/app/components/filament-dialog/filament-dialog.component.html @@ -0,0 +1,225 @@ + + + + +

{{ dialogTitle() }}

+ + + @if (lookupsLoading()) { +
+ +

Loading material options…

+
+ } + + + @if (!lookupsLoading()) { +
+ + + @if (serverError()) { + + } + + +
+

Material

+ + + + Base Material + + @for (base of materialBases(); track base.id) { + {{ base.name }} + } + + @if (form.get('materialBaseId')!.hasError('required') && form.get('materialBaseId')!.touched) { + Base material is required + } + + + + + Finish + + Select a base material first + @for (finish of filteredFinishes(); track finish.id) { + {{ finish.name }} + } + + @if (form.get('materialFinishId')!.hasError('required') && form.get('materialFinishId')!.touched) { + Finish is required + } + @if (filteredFinishes().length === 0 && form.get('materialBaseId')!.value) { + No finishes available for this material + } + + + + + Modifier (optional) + + None + @for (modifier of filteredModifiers(); track modifier.id) { + {{ modifier.name }} + } + + @if (filteredModifiers().length === 0 && form.get('materialBaseId')!.value) { + No modifiers available for this material + } + +
+ + +
+

Spool Details

+ + + + Brand + + @if (form.get('brand')!.hasError('required') && form.get('brand')!.touched) { + Brand is required + } + + + + + Serial Number + + @if (form.get('spoolSerial')!.hasError('required') && form.get('spoolSerial')!.touched) { + Serial number is required + } + + + +
+ + Color Name + + @if (form.get('colorName')!.hasError('required') && form.get('colorName')!.touched) { + Color name is required + } + + + + Color Hex + + + + + @if (form.get('colorHex')!.hasError('required') && form.get('colorHex')!.touched) { + Color hex is required + } + @if (form.get('colorHex')!.hasError('pattern') && form.get('colorHex')!.touched) { + Must be #RRGGBB format + } + +
+
+ + +
+

Weight & Dimensions

+ +
+ + + Diameter (mm) + + @if (form.get('filamentDiameterMm')!.hasError('required') && form.get('filamentDiameterMm')!.touched) { + Diameter is required + } + +
+ +
+ + + Total Weight (g) + + Full spool weight + @if (form.get('weightTotalGrams')!.hasError('required') && form.get('weightTotalGrams')!.touched) { + Total weight is required + } + + + + + Remaining Weight (g) + + Current remaining + @if (form.get('weightRemainingGrams')!.hasError('required') && form.get('weightRemainingGrams')!.touched) { + Remaining weight is required + } + @if (form.get('weightRemainingGrams')!.hasError('exceedsTotal')) { + Cannot exceed total weight + } + +
+
+ + +
+

Purchase & Status

+ +
+ + + Price + + $ + @if (form.get('purchasePrice')!.hasError('min') && form.get('purchasePrice')!.touched) { + Price must be non-negative + } + + + + + Purchase Date + + + + +
+ + +
+ + Spool is active and available for use + +
+
+ +
+ } + +
+ + + + + + \ No newline at end of file diff --git a/frontend/src/app/components/filament-dialog/filament-dialog.component.scss b/frontend/src/app/components/filament-dialog/filament-dialog.component.scss new file mode 100644 index 0000000..9bc5031 --- /dev/null +++ b/frontend/src/app/components/filament-dialog/filament-dialog.component.scss @@ -0,0 +1,175 @@ +/** + * Filament Dialog Styles + * Touch-optimized for kiosk (Raspberry Pi 5) and mobile PWA + */ + +$touch-target-min: 48px; +$spacing-unit: 8px; +$color-error: #ef4444; + +// ── Dialog Layout ────────────────────────────────────────── + +.filament-dialog-content { + overflow-y: auto; + max-height: 70vh; + padding: 0 $spacing-unit * 2; + + @media (max-width: 480px) { + padding: 0 $spacing-unit; + } +} + +[mat-dialog-title] { + margin: 0 0 $spacing-unit * 2 0; + padding: $spacing-unit * 2 0 0 0; + font-size: 20px; + font-weight: 600; +} + +// ── Loading State ────────────────────────────────────────── + +.dialog-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px $spacing-unit * 2; + color: var(--mat-sys-on-surface-variant); + + p { + margin-top: $spacing-unit * 2; + font-size: 14px; + } +} + +// ── Error Banner ─────────────────────────────────────────── + +.error-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; + background-color: rgba($color-error, 0.12); + color: $color-error; + border: 1px solid rgba($color-error, 0.3); + font-size: 14px; + font-weight: 500; + + mat-icon { + font-size: 20px !important; + width: 20px !important; + height: 20px !important; + } +} + +// ── Form Sections ────────────────────────────────────────── + +.form-section { + margin-bottom: $spacing-unit * 3; + + .section-title { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--mat-sys-on-surface-variant); + margin: 0 0 $spacing-unit * 1.5 0; + padding-bottom: $spacing-unit * 0.5; + border-bottom: 1px solid var(--mat-sys-outline-variant); + } +} + +.filament-form { + display: flex; + flex-direction: column; + gap: $spacing-unit; +} + +// ── Form Fields ──────────────────────────────────────────── + +.form-field { + width: 100%; + + // Touch target sizing + .mat-mdc-form-field-subscript-wrapper { + min-height: 20px; + } +} + +.form-row { + display: flex; + gap: $spacing-unit * 2; + width: 100%; + + .form-field { + flex: 1; + } + + @media (max-width: 480px) { + flex-direction: column; + gap: 0; + } +} + +// ── Color Hex Preview ────────────────────────────────────── + +.color-hex-field { + max-width: 180px; + + @media (max-width: 480px) { + max-width: 100%; + } +} + +.color-preview { + display: inline-flex; + align-items: center; + margin-left: 4px; +} + +.color-swatch-mini { + display: inline-block; + width: 20px; + height: 20px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + vertical-align: middle; +} + +// ── Checkbox Row ─────────────────────────────────────────── + +.checkbox-row { + display: flex; + align-items: center; + padding: $spacing-unit 0; + + mat-checkbox { + min-height: $touch-target-min; + display: flex; + align-items: center; + } +} + +// ── Save Button Spinner ──────────────────────────────────── + +mat-dialog-actions { + padding: $spacing-unit $spacing-unit * 2 $spacing-unit * 2; + gap: $spacing-unit; + + button { + min-height: $touch-target-min; + min-width: 100px; + } +} + +.btn-spinner { + display: inline-block; + margin-right: $spacing-unit; + vertical-align: middle; + + circle { + stroke: currentColor; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/filament-dialog/filament-dialog.component.ts b/frontend/src/app/components/filament-dialog/filament-dialog.component.ts new file mode 100644 index 0000000..ffff1ab --- /dev/null +++ b/frontend/src/app/components/filament-dialog/filament-dialog.component.ts @@ -0,0 +1,277 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, + computed, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { Filament } from '../../models/filament.model'; +import { + MaterialBase, + MaterialFinish, + MaterialModifier, +} from '../../models/material.model'; +import { + FilamentService, + CreateFilamentRequest, + UpdateFilamentRequest, +} from '../../services/filament.service'; + +/** Data passed into the dialog from the opener. */ +export interface FilamentDialogData { + /** If provided, the dialog opens in edit mode with pre-populated fields. */ + filament?: Filament; +} + +@Component({ + selector: 'app-filament-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDatepickerModule, + MatNativeDateModule, + MatCheckboxModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule, + ], + templateUrl: './filament-dialog.component.html', + styleUrl: './filament-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilamentDialogComponent { + private readonly dialogRef = inject(MatDialogRef); + private readonly data = inject(MAT_DIALOG_DATA); + private readonly fb = inject(FormBuilder); + private readonly filamentService = inject(FilamentService); + + /** Whether this dialog is in edit mode (has existing filament data). */ + readonly isEditMode = computed(() => !!this.data.filament); + + /** Dialog title based on mode. */ + readonly dialogTitle = computed(() => + this.isEditMode() ? 'Edit Filament' : 'Add Filament' + ); + + // ── Lookup data signals ────────────────────────────────── + + /** All material bases for the base material dropdown. */ + readonly materialBases = signal([]); + + /** Material finishes filtered by selected base material. */ + readonly filteredFinishes = signal([]); + + /** Material modifiers filtered by selected base material. */ + readonly filteredModifiers = signal([]); + + /** Whether material lookups are loading. */ + readonly lookupsLoading = signal(true); + + /** Whether the save operation is in progress. */ + readonly saving = signal(false); + + /** Server error message, if any. */ + readonly serverError = signal(null); + + // ── Form ───────────────────────────────────────────────── + + readonly form: FormGroup = this.fb.group({ + materialBaseId: ['', Validators.required], + materialFinishId: ['', Validators.required], + materialModifierId: [null], + brand: ['', [Validators.required, Validators.maxLength(200)]], + colorName: ['', [Validators.required, Validators.maxLength(200)]], + colorHex: ['#000000', [Validators.required, Validators.pattern(/^#[0-9A-Fa-f]{6}$/)]], + weightTotalGrams: [1000, [Validators.required, Validators.min(0.01), Validators.max(100000)]], + weightRemainingGrams: [1000, [Validators.required, Validators.min(0), Validators.max(100000)]], + filamentDiameterMm: [1.75, [Validators.required, Validators.min(0.1), Validators.max(10)]], + spoolSerial: ['', [Validators.required, Validators.maxLength(200)]], + purchasePrice: [null, [Validators.min(0), Validators.max(1000000)]], + purchaseDate: [null], + isActive: [true], + }); + + constructor() { + this.loadLookups(); + this.patchFormIfEditing(); + this.setupCascadingFilters(); + } + + // ── Data loading ───────────────────────────────────────── + + /** Load material bases, finishes, and modifiers for dropdowns. */ + private loadLookups(): void { + this.lookupsLoading.set(true); + this.filamentService.getMaterialBases().subscribe({ + next: (bases) => { + this.materialBases.set(bases); + this.lookupsLoading.set(false); + }, + error: () => { + this.lookupsLoading.set(false); + this.serverError.set('Failed to load material options. Please try again.'); + }, + }); + } + + /** Pre-populate form fields when editing an existing filament. */ + private patchFormIfEditing(): void { + if (this.data.filament) { + const f = this.data.filament; + this.form.patchValue({ + materialBaseId: f.materialBaseId, + materialFinishId: f.materialFinishId, + materialModifierId: f.materialModifierId, + brand: f.brand, + colorName: f.colorName, + colorHex: f.colorHex, + weightTotalGrams: f.weightTotalGrams, + weightRemainingGrams: f.weightRemainingGrams, + filamentDiameterMm: f.filamentDiameterMm, + spoolSerial: f.spoolSerial, + purchasePrice: f.purchasePrice, + purchaseDate: f.purchaseDate ? new Date(f.purchaseDate) : null, + isActive: f.isActive, + }); + } + } + + /** Set up cascading filter: when base material changes, reload finishes & modifiers. */ + private setupCascadingFilters(): void { + this.form.get('materialBaseId')!.valueChanges.subscribe((baseId: string | null) => { + // Clear dependent selections when base changes + this.form.get('materialFinishId')!.setValue(''); + this.form.get('materialModifierId')!.setValue(null); + this.filteredFinishes.set([]); + this.filteredModifiers.set([]); + + if (!baseId) return; + + this.filamentService.getMaterialFinishes(baseId).subscribe({ + next: (finishes) => this.filteredFinishes.set(finishes), + error: () => this.filteredFinishes.set([]), + }); + + this.filamentService.getMaterialModifiers(baseId).subscribe({ + next: (modifiers) => this.filteredModifiers.set(modifiers), + error: () => this.filteredModifiers.set([]), + }); + }); + + // If editing, trigger the cascading load for the pre-selected base + if (this.data.filament) { + const baseId = this.data.filament.materialBaseId; + // We need to load finishes and modifiers for the pre-selected base + // but also re-select the original finish and modifier after loading + this.filamentService.getMaterialFinishes(baseId).subscribe({ + next: (finishes) => { + this.filteredFinishes.set(finishes); + // Re-patch finish after load + this.form.get('materialFinishId')!.setValue(this.data.filament!.materialFinishId); + }, + }); + this.filamentService.getMaterialModifiers(baseId).subscribe({ + next: (modifiers) => { + this.filteredModifiers.set(modifiers); + // Re-patch modifier after load + this.form.get('materialModifierId')!.setValue(this.data.filament!.materialModifierId); + }, + }); + } + } + + // ── Actions ────────────────────────────────────────────── + + /** Cancel and close the dialog without saving. */ + cancel(): void { + this.dialogRef.close(false); + } + + /** Submit the form — creates or updates the filament. */ + save(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + // Cross-field validation: remaining weight must not exceed total weight + const total = this.form.value.weightTotalGrams; + const remaining = this.form.value.weightRemainingGrams; + if (remaining > total) { + this.form.get('weightRemainingGrams')!.setErrors({ exceedsTotal: true }); + return; + } + + this.saving.set(true); + this.serverError.set(null); + + const formValue = this.form.value; + const request: CreateFilamentRequest | UpdateFilamentRequest = { + materialBaseId: formValue.materialBaseId, + materialFinishId: formValue.materialFinishId, + materialModifierId: formValue.materialModifierId || null, + brand: formValue.brand.trim(), + colorName: formValue.colorName.trim(), + colorHex: formValue.colorHex, + weightTotalGrams: formValue.weightTotalGrams, + weightRemainingGrams: formValue.weightRemainingGrams, + filamentDiameterMm: formValue.filamentDiameterMm, + spoolSerial: formValue.spoolSerial.trim(), + purchasePrice: formValue.purchasePrice ?? null, + purchaseDate: formValue.purchaseDate + ? new Date(formValue.purchaseDate).toISOString() + : null, + isActive: formValue.isActive, + }; + + if (this.isEditMode()) { + const id = this.data.filament!.id; + this.filamentService.updateFilament(id, request).subscribe({ + next: (updated) => { + this.saving.set(false); + this.dialogRef.close(true); + }, + error: (err) => { + this.saving.set(false); + this.serverError.set( + err?.error?.error || err?.message || 'Failed to update filament. Please try again.' + ); + }, + }); + } else { + this.filamentService.createFilament(request).subscribe({ + next: (created) => { + this.saving.set(false); + this.dialogRef.close(true); + }, + error: (err) => { + this.saving.set(false); + this.serverError.set( + err?.error?.error || err?.message || 'Failed to create filament. Please try again.' + ); + }, + }); + } + } +} \ 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..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 @@
+ +
+

Filament Inventory

+ +
+ @if (criticalCount() > 0) {