From 0b4daed512892a3396c2a04f560ca6a2329948fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Feb 2026 15:03:18 +0100 Subject: [PATCH] feat(web) improvements in ui for calculator --- .../calculator/calculator-page.component.ts | 26 +- .../upload-form/upload-form.component.ts | 18 +- .../services/quote-estimator.service.ts | 246 +++++++++++++----- 3 files changed, 206 insertions(+), 84 deletions(-) diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index f48349c..457ec75 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -39,13 +39,14 @@ import { Router } from '@angular/router'; -
+
@if (error()) { Si รจ verificato un errore durante il calcolo del preventivo. } @@ -86,6 +87,13 @@ import { Router } from '@angular/router'; } } + .centered-col { + align-self: flex-start; /* Default */ + @media(min-width: 768px) { + align-self: center; + } + } + /* Mode Selector (Segmented Control style) */ .mode-selector { display: flex; @@ -166,21 +174,29 @@ import { Router } from '@angular/router'; export class CalculatorPageComponent { mode = signal('easy'); loading = signal(false); + uploadProgress = signal(0); result = signal(null); error = signal(false); constructor(private estimator: QuoteEstimatorService, private router: Router) {} onCalculate(req: QuoteRequest) { - this.currentRequest = req; // Store request for consultation + this.currentRequest = req; this.loading.set(true); + this.uploadProgress.set(0); this.error.set(false); this.result.set(null); this.estimator.calculate(req).subscribe({ - next: (res) => { - this.result.set(res); - this.loading.set(false); + next: (event) => { + if (typeof event === 'number') { + this.uploadProgress.set(event); + } else { + // It's the result + this.result.set(event as QuoteResult); + this.loading.set(false); + this.uploadProgress.set(100); + } }, error: () => { this.error.set(true); diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index a7664b1..1a92133 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -102,13 +102,12 @@ import { QuoteRequest } from '../../services/quote-estimator.service'; }
- - @if (loading()) { + + @if (loading() && uploadProgress() < 100) {
-
+
-
} @@ -116,7 +115,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service'; type="submit" [disabled]="form.invalid || loading()" [fullWidth]="true"> - {{ loading() ? 'Processing...' : ('CALC.CALCULATE' | translate) }} + {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
@@ -205,20 +204,15 @@ import { QuoteRequest } from '../../services/quote-estimator.service'; height: 100%; background: var(--color-brand); width: 0%; - animation: progress 2s ease-in-out infinite; + transition: width 0.2s ease-out; } .progress-text { font-size: 0.875rem; color: var(--color-text-muted); } - - @keyframes progress { - 0% { width: 0%; transform: translateX(-100%); } - 50% { width: 100%; transform: translateX(0); } - 100% { width: 100%; transform: translateX(100%); } - } `] }) export class UploadFormComponent { mode = input<'easy' | 'advanced'>('easy'); loading = input(false); + uploadProgress = input(0); submitRequest = output(); form: FormGroup; 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 d278ee4..64d434c 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -44,80 +44,192 @@ interface BackendResponse { export class QuoteEstimatorService { private http = inject(HttpClient); - calculate(request: QuoteRequest): Observable { - const requests: Observable[] = request.files.map(file => { - const formData = new FormData(); - formData.append('file', file); - formData.append('machine', 'bambu_a1'); // Hardcoded for now - formData.append('filament', this.mapMaterial(request.material)); - formData.append('quality', this.mapQuality(request.quality)); - - if (request.mode === 'advanced') { - if (request.color) formData.append('material_color', request.color); - 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'); - } + calculate(request: QuoteRequest): Observable { + const formData = new FormData(); + // Assuming single file primarily for now, or aggregating. + // The current UI seems to select one "active" file or handle multiple. + // The logic below was mapping multiple files to multiple requests. + // To support progress seamlessly for the "main" action, let's focus on the processing flow. + // If multiple files, we might need a more complex progress tracking or just track the first/total. + // Given the UI shows one big "Analyse" button, let's treat it as a batch or single. + + // NOTE: The previous logic did `request.files.map(...)`. + // If we want a global progress, we can mistakenly complexity it. + // Let's assume we upload all files in one request if the API supported it, but the API seems to be 1 file per request from previous code? + // "formData.append('file', file)" inside the map implies multiple requests. + // To keep it simple and working with the progress bar which is global: + // We will emit progress for the *current* file being processed or average them. + // OR simpler: The user typically uploads one file for a quote? + // The UI `files: File[]` allows multiple. + // Let's stick to the previous logic but wrap it to emit progress. + // However, forkJoin waits for all. We can't easily get specialized progress for "overall upload" with forkJoin of distinct requests easily without merging. + + // Refined approach: + // We will process files IN PARALLEL (forkJoin) but we can't easily track aggregated upload progress of multiple requests in a single simple number without extra code. + // BUT, the user wants "la barra di upload". + // If we assume standard use case is 1 file, it's easy. + // If multiple, we can emit progress as "average of all uploads" or just "uploading...". + // Let's modify the signature to return `Observable<{ type: 'progress' | 'result', value: any }>` or similar? + // The plan said `Observable` originally, now we need progress. + // Let's change return type to `Observable` or a specific union. + + // Let's handle just the first file for progress visualization simplicity if multiple are present, + // or better, create a wrapper that merges the progress. + + // Actually, looking at the previous code: `const requests = request.files.map(...)`. + // If we have 3 files, we have 3 requests. + // We can emit progress events. + + // START implementation for generalized progress: + + const file = request.files[0]; // Primary target for now to ensure we have a progress to show. + // Ideally we should upload all. + + // For this task, to satisfy "bar disappears after upload", we really need to know when upload finishes. + + // Let's keep it robust: + // If multiple files, we likely want to just process them. + // Let's stick to the previous logic but capture progress events for at least one or all. + + if (request.files.length === 0) return of(); - const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) { - // @ts-ignore - headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - } - - console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`); - return this.http.post(`${environment.apiUrl}/api/quote`, formData, { headers }).pipe( - map(res => { - console.log('Response for', file.name, res); - return res; - }), - catchError(err => { - console.error('Error calculating quote for', file.name, err); - return of({ success: false, data: { print_time_seconds: 0, material_grams: 0, cost: { total: 0 } }, error: err.message }); - }) - ); - }); - - return forkJoin(requests).pipe( - map(responses => { - console.log('All responses:', responses); + // We will change the architecture slightly: + // We will execute requests and for EACH, we track progress. + // But we only have one boolean 'loading' and one 'progress' bar in UI. + // Let's average the progress? + + // Simplification: The user probably uploads one file to check quote. + // Let's implement support for the first file's progress to drive the UI bar, handling the rest in background/parallel. + + // Re-implementing the single file logic from the map, but enabled for progress. + + return new Observable(observer => { + let completed = 0; + let total = request.files.length; + const results: BackendResponse[] = []; + let grandTotal = 0; // For progress calculation if we wanted to average - const validResponses = responses.filter(r => r.success); - if (validResponses.length === 0 && responses.length > 0) { - throw new Error('All calculations failed. Check backend connection.'); - } + // We'll just track the "upload phase" of the bundle. + // Actually, let's just use `concat` or `merge`? + // Let's simplify: We will only track progress for the first file or "active" file. + // But the previous code sent ALL files. + + // Let's change the return type to emit events. + + const uploads = request.files.map(file => { + const formData = new FormData(); + formData.append('file', file); + formData.append('machine', 'bambu_a1'); + formData.append('filament', this.mapMaterial(request.material)); + formData.append('quality', this.mapQuality(request.quality)); + if (request.mode === 'advanced') { + if (request.color) formData.append('material_color', request.color); + 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'); + } + + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - let totalPrice = 0; - let totalTime = 0; - let totalWeight = 0; - let setupCost = 10; // Base setup - - validResponses.forEach(res => { - totalPrice += res.data.cost.total; - totalTime += res.data.print_time_seconds; - totalWeight += res.data.material_grams; + return this.http.post(`${environment.apiUrl}/api/quote`, formData, { + headers, + reportProgress: true, + observe: 'events' + }).pipe( + map(event => ({ file, event })), + catchError(err => of({ file, error: err })) + ); }); - // Apply quantity multiplier - totalPrice = (totalPrice * request.quantity) + setupCost; - totalWeight = totalWeight * request.quantity; - // Total time usually parallel if we have multiple printers, but let's sum for now - totalTime = totalTime * request.quantity; + // We process all uploads. + // We want to emit: + // 1. Progress updates (average of all files?) + // 2. Final QuoteResult + + const allProgress: number[] = new Array(request.files.length).fill(0); + let completedRequests = 0; + const finalResponses: any[] = []; - const totalHours = Math.floor(totalTime / 3600); - const totalMinutes = Math.ceil((totalTime % 3600) / 60); + // Subscribe to all + uploads.forEach((obs, index) => { + obs.subscribe({ + next: (wrapper: any) => { + if (wrapper.error) { + // handled in final calculation + finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } }; + return; + } - return { - price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals - currency: 'CHF', - printTimeHours: totalHours, - printTimeMinutes: totalMinutes, - materialUsageGrams: Math.ceil(totalWeight), - setupCost - }; - }) - ); + const event = wrapper.event; + if (event.type === 1) { // HttpEventType.UploadProgress + if (event.total) { + const percent = Math.round((100 * event.loaded) / event.total); + allProgress[index] = percent; + // Emit average progress + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total); + observer.next(avg); // Emit number for progress + } + } else if (event.type === 4) { // HttpEventType.Response + allProgress[index] = 100; + finalResponses[index] = event.body; + completedRequests++; + + if (completedRequests === total) { + // All done + observer.next(100); // Ensure complete + + // Calculate Totals + const valid = finalResponses.filter(r => r && r.success); + if (valid.length === 0 && finalResponses.length > 0) { + observer.error('All calculations failed.'); + return; + } + + let totalPrice = 0; + let totalTime = 0; + let totalWeight = 0; + let setupCost = 10; + + valid.forEach(res => { + totalPrice += res.data.cost.total; + totalTime += res.data.print_time_seconds; + totalWeight += res.data.material_grams; + }); + + totalPrice = (totalPrice * request.quantity) + setupCost; + totalWeight = totalWeight * request.quantity; + totalTime = totalTime * request.quantity; + + const totalHours = Math.floor(totalTime / 3600); + const totalMinutes = Math.ceil((totalTime % 3600) / 60); + + const result: QuoteResult = { + price: Math.round(totalPrice * 100) / 100, + currency: 'CHF', + printTimeHours: totalHours, + printTimeMinutes: totalMinutes, + materialUsageGrams: Math.ceil(totalWeight), + setupCost + }; + + observer.next(result); // Emit final object + observer.complete(); + } + } + }, + error: (err) => { + console.error('Error in request', err); + finalResponses[index] = { success: false }; + completedRequests++; + if (completedRequests === total) { + observer.error('Requests failed'); + } + } + }); + }); + }); } private mapMaterial(mat: string): string {