diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 3b4de2a..e7c8620 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject, signal } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, forkJoin, of } from 'rxjs'; +import { HttpClient, HttpEventType } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; @@ -9,7 +9,6 @@ export interface QuoteRequest { material: string; quality: string; notes?: string; - // color removed from global scope infillDensity?: number; infillPattern?: string; supportEnabled?: boolean; @@ -26,32 +25,133 @@ export interface QuoteItem { quantity: number; material?: string; color?: string; - // Computed values for UI convenience (optional, can be done in component) } export interface QuoteResult { items: QuoteItem[]; setupCost: number; currency: string; - // The following are aggregations that can be re-calculated totalPrice: number; totalTimeHours: number; totalTimeMinutes: number; totalWeight: number; } -// ... (skip down to calculate logic) + +interface BackendResponse { + success: boolean; + data: { + print_time_seconds: number; + material_grams: number; + cost: { + total: number; + }; + }; + error?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class QuoteEstimatorService { + private http = inject(HttpClient); + + calculate(request: QuoteRequest): Observable { + if (request.items.length === 0) return of(); + + return new Observable(observer => { + const totalItems = request.items.length; + const allProgress: number[] = new Array(totalItems).fill(0); + const finalResponses: any[] = []; + let completedRequests = 0; + + const uploads = request.items.map((item, index) => { + const formData = new FormData(); + formData.append('file', item.file); + formData.append('machine', 'bambu_a1'); + formData.append('filament', this.mapMaterial(request.material)); + formData.append('quality', this.mapQuality(request.quality)); + + // Send color for both modes if present, defaulting to Black + formData.append('material_color', item.color || 'Black'); + + if (request.mode === 'advanced') { + if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); + if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); + if (request.supportEnabled) formData.append('support_enabled', 'true'); + if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString()); + if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString()); + } + + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + + return this.http.post(`${environment.apiUrl}/api/quote`, formData, { + headers, + reportProgress: true, + observe: 'events' + }).pipe( + map(event => ({ item, event, index })), + catchError(err => of({ item, error: err, index })) + ); + }); + + // Subscribe to all + uploads.forEach((obs) => { + obs.subscribe({ + next: (wrapper: any) => { + const idx = wrapper.index; + + if (wrapper.error) { + finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; + // Even if error, we count as complete + // But we need to handle completion logic carefully. + // For simplicity, let's treat it as complete but check later. + } + + const event = wrapper.event; + if (event && event.type === HttpEventType.UploadProgress) { + if (event.total) { + const percent = Math.round((100 * event.loaded) / event.total); + allProgress[idx] = percent; + // Emit average progress + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); + observer.next(avg); + } + } else if ((event && event.type === HttpEventType.Response) || wrapper.error) { + // It's done (either response or error caught above) + if (!finalResponses[idx]) { // only if not already set by error + allProgress[idx] = 100; + if (wrapper.error) { + finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; + } else { + finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity }; + } + completedRequests++; + } + + if (completedRequests === totalItems) { + // All done + observer.next(100); + + // Calculate Results + let setupCost = 10; + + if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) { + setupCost += 2; + } + + const items: QuoteItem[] = []; + finalResponses.forEach((res, idx) => { if (res && res.success) { - // Find original item to get color - const originalItem = request.items[idx]; - // Note: responses and request.items are index-aligned because we mapped them - + const originalItem = request.items[idx]; items.push({ fileName: res.fileName, unitPrice: res.data.cost.total, unitTime: res.data.print_time_seconds, unitWeight: res.data.material_grams, - quantity: res.originalQty, + quantity: res.originalQty, // Use the requested quantity material: request.material, color: originalItem.color || 'Default' }); @@ -59,6 +159,8 @@ export interface QuoteResult { }); if (items.length === 0) { + // If at least one failed? Or all? + // For now if NO items succeeded, error. observer.error('All calculations failed.'); return; } @@ -93,9 +195,10 @@ export interface QuoteResult { } }, error: (err) => { - console.error('Error in request', err); + console.error('Error in request subscription', err); + // Should be caught by inner pipe, but safety net completedRequests++; - if (completedRequests === totalItems) { + if (completedRequests === totalItems) { observer.error('Requests failed'); } }