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()) { + + error + {{ 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 + + + + + + } + + + + + + + Cancel + + + @if (saving()) { + + } + {{ isEditMode() ? 'Save Changes' : 'Add Filament' }} + + \ 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 + + add + Add Filament + + + @if (criticalCount() > 0) { @@ -106,6 +116,20 @@ + + + + + + edit + + + + ([]); @@ -65,6 +77,7 @@ export class FilamentTableComponent { 'remaining', 'stockLevel', 'status', + 'actions', ]); /** Default columns for template binding */ @@ -293,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/models/material.model.ts b/frontend/src/app/models/material.model.ts new file mode 100644 index 0000000..2feb7ea --- /dev/null +++ b/frontend/src/app/models/material.model.ts @@ -0,0 +1,50 @@ +/** + * Material lookup models matching the Extrudex backend Material DTOs. + * Used for populating dropdowns in the filament add/edit form. + */ + +/** Material base (e.g., PLA, PETG, ABS). */ +export interface MaterialBase { + /** Unique identifier. */ + id: string; + /** Human-readable name (e.g., "PLA", "PETG"). */ + name: string; + /** Density in g/cm³. */ + densityGperCm3: number; + /** Created timestamp (UTC). */ + createdAt: string; + /** Updated timestamp (UTC). */ + updatedAt: string; +} + +/** Material finish (e.g., Basic, Matte, Silk). */ +export interface MaterialFinish { + /** Unique identifier. */ + id: string; + /** Human-readable name (e.g., "Basic", "Matte"). */ + name: string; + /** Foreign key to the parent material base. */ + materialBaseId: string; + /** Name of the parent material base (for display). */ + materialBaseName: string; + /** Created timestamp (UTC). */ + createdAt: string; + /** Updated timestamp (UTC). */ + updatedAt: string; +} + +/** Material modifier (e.g., Carbon Fiber, Wood Fill). Optional. */ +export interface MaterialModifier { + /** Unique identifier. */ + id: string; + /** Human-readable name (e.g., "Carbon Fiber"). */ + name: string; + /** Foreign key to the parent material base. */ + materialBaseId: string; + /** Name of the parent material base (for display). */ + materialBaseName: string; + /** Created timestamp (UTC). */ + createdAt: string; + /** Updated timestamp (UTC). */ + updatedAt: string; +} \ No newline at end of file diff --git a/frontend/src/app/models/paged-response.model.ts b/frontend/src/app/models/paged-response.model.ts new file mode 100644 index 0000000..2105c6f --- /dev/null +++ b/frontend/src/app/models/paged-response.model.ts @@ -0,0 +1,13 @@ +/** + * Generic paged response wrapper matching the Extrudex backend PagedResponse. + */ +export interface PagedResponse { + /** The items in this page. */ + items: T[]; + /** Total number of items across all pages. */ + totalCount: number; + /** The current page number (1-based). */ + pageNumber: number; + /** The number of items per page. */ + pageSize: number; +} \ No newline at end of file diff --git a/frontend/src/app/services/filament.service.ts b/frontend/src/app/services/filament.service.ts new file mode 100644 index 0000000..130c234 --- /dev/null +++ b/frontend/src/app/services/filament.service.ts @@ -0,0 +1,134 @@ +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'; + +/** + * Request body for creating a new filament spool. + * Matches the backend CreateFilamentRequest DTO. + */ +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; +} + +/** + * 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 ──────────────────────────────────────── + + /** + * Get a paginated list of filament 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 } + ); + } + + /** + * Get a single filament spool by 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 diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..a789264 --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,8 @@ +/** + * Environment configuration for the Extrudex frontend (production). + * Override API URL for deployed environments. + */ +export const environment = { + production: true, + apiBaseUrl: '/api', +}; \ No newline at end of file diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..73532bd --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,8 @@ +/** + * Environment configuration for the Extrudex frontend. + * Replace API URL with the actual backend endpoint in production. + */ +export const environment = { + production: false, + apiBaseUrl: 'http://localhost:5000', +}; \ No newline at end of file
Loading material options…