From 5bc698815cb82c6918d25e3ef5f78c136d882126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 5 Feb 2026 17:12:18 +0100 Subject: [PATCH] fix(back-end): update profile inheritance --- .../quote-result/quote-result.component.ts | 166 ++++++++++++++- .../services/quote-estimator.service.ts | 192 +++++++----------- 2 files changed, 230 insertions(+), 128 deletions(-) diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 1a2e9da..134a7f7 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -1,36 +1,73 @@ -import { Component, input, output } from '@angular/core'; +import { Component, input, output, signal, computed, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component'; -import { QuoteResult } from '../../services/quote-estimator.service'; +import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service'; @Component({ selector: 'app-quote-result', standalone: true, - imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent], + imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent], template: `

{{ 'CALC.RESULT' | translate }}

+ +
+ @for (item of items(); track item.fileName; let i = $index) { +
+
+ {{ item.fileName }} + + {{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g + +
+ +
+
+ + +
+
+ {{ (item.unitPrice * item.quantity) | currency:result().currency }} +
+
+
+ } +
+ +
+ +
- {{ result().price | currency:result().currency }} + {{ totals().price | currency:result().currency }} - {{ result().printTimeHours }}h {{ result().printTimeMinutes }}m + {{ totals().hours }}h {{ totals().minutes }}m - {{ result().materialUsageGrams }}g + {{ totals().weight }}g
+ +
+ * Include {{ result().setupCost | currency:result().currency }} Setup Cost +
{{ 'CALC.ORDER' | translate }} @@ -40,18 +77,133 @@ import { QuoteResult } from '../../services/quote-estimator.service'; `, styles: [` .title { margin-bottom: var(--space-6); text-align: center; } + + .divider { + height: 1px; + background: var(--color-border); + margin: var(--space-4) 0; + } + + .items-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-4); + } + + .item-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3); + background: var(--color-neutral-50); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + } + + .item-info { + display: flex; + flex-direction: column; + } + + .file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); } + .file-details { font-size: 0.8rem; color: var(--color-text-muted); } + + .item-controls { + display: flex; + align-items: center; + gap: var(--space-4); + } + + .qty-control { + display: flex; + align-items: center; + gap: var(--space-2); + + label { font-size: 0.8rem; color: var(--color-text-muted); } + } + + .qty-input { + width: 60px; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + text-align: center; + &:focus { outline: none; border-color: var(--color-brand); } + } + + .item-price { + font-weight: 600; + min-width: 60px; + text-align: right; + } + .result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); - margin-bottom: var(--space-6); + margin-bottom: var(--space-2); } .full-width { grid-column: span 2; } + .setup-note { + text-align: center; + margin-bottom: var(--space-6); + color: var(--color-text-muted); + font-size: 0.8rem; + } + .actions { display: flex; flex-direction: column; gap: var(--space-3); } `] }) export class QuoteResultComponent { result = input.required(); consult = output(); + + // Local mutable state for items to handle quantity changes + items = signal([]); + + constructor() { + effect(() => { + // Initialize local items when result inputs change + // We map to new objects to avoid mutating the input directly if it was a reference + this.items.set(this.result().items.map(i => ({...i}))); + }, { allowSignalWrites: true }); + } + + updateQuantity(index: number, newQty: number | string) { + const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty; + if (qty < 1 || isNaN(qty)) return; + + this.items.update(current => { + const updated = [...current]; + updated[index] = { ...updated[index], quantity: qty }; + return updated; + }); + } + + totals = computed(() => { + const currentItems = this.items(); + const setup = this.result().setupCost; + + let price = setup; + let time = 0; + let weight = 0; + + currentItems.forEach(i => { + price += i.unitPrice * i.quantity; + time += i.unitTime * i.quantity; + weight += i.unitWeight * i.quantity; + }); + + const hours = Math.floor(time / 3600); + const minutes = Math.ceil((time % 3600) / 60); + + return { + price: Math.round(price * 100) / 100, + hours, + minutes, + weight: Math.ceil(weight) + }; + }); } 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 64d434c..0d17653 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -5,10 +5,9 @@ import { map, catchError } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; export interface QuoteRequest { - files: File[]; + items: { file: File, quantity: number }[]; material: string; quality: string; - quantity: number; notes?: string; color?: string; infillDensity?: number; @@ -17,13 +16,24 @@ export interface QuoteRequest { mode: 'easy' | 'advanced'; } +export interface QuoteItem { + fileName: string; + unitPrice: number; + unitTime: number; // seconds + unitWeight: number; // grams + quantity: number; + // Computed values for UI convenience (optional, can be done in component) +} + export interface QuoteResult { - price: number; - currency: string; - printTimeHours: number; - printTimeMinutes: number; - materialUsageGrams: number; + items: QuoteItem[]; setupCost: number; + currency: string; + // The following are aggregations that can be re-calculated + totalPrice: number; + totalTimeHours: number; + totalTimeMinutes: number; + totalWeight: number; } interface BackendResponse { @@ -45,80 +55,17 @@ export class QuoteEstimatorService { private http = inject(HttpClient); 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(); - - // 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. + if (request.items.length === 0) return of(); 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 - - // 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 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', file); + formData.append('file', item.file); formData.append('machine', 'bambu_a1'); formData.append('filament', this.mapMaterial(request.material)); formData.append('quality', this.mapQuality(request.quality)); @@ -138,27 +85,19 @@ export class QuoteEstimatorService { reportProgress: true, observe: 'events' }).pipe( - map(event => ({ file, event })), - catchError(err => of({ file, error: err })) + map(event => ({ item, event, index })), + catchError(err => of({ item, error: err, index })) ); }); - // 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[] = []; - // Subscribe to all - uploads.forEach((obs, index) => { + uploads.forEach((obs) => { obs.subscribe({ next: (wrapper: any) => { + const idx = wrapper.index; + if (wrapper.error) { - // handled in final calculation - finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } }; + finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; return; } @@ -166,64 +105,75 @@ export class QuoteEstimatorService { if (event.type === 1) { // HttpEventType.UploadProgress if (event.total) { const percent = Math.round((100 * event.loaded) / event.total); - allProgress[index] = percent; + allProgress[idx] = percent; // Emit average progress - const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total); - observer.next(avg); // Emit number for progress + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); + observer.next(avg); } } else if (event.type === 4) { // HttpEventType.Response - allProgress[index] = 100; - finalResponses[index] = event.body; + allProgress[idx] = 100; + finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity }; completedRequests++; - if (completedRequests === total) { + if (completedRequests === totalItems) { // All done - observer.next(100); // Ensure complete + observer.next(100); - // Calculate Totals - const valid = finalResponses.filter(r => r && r.success); - if (valid.length === 0 && finalResponses.length > 0) { + // Calculate Results + const setupCost = 10; + + const items: QuoteItem[] = []; + + finalResponses.forEach(res => { + if (res && res.success) { + items.push({ + fileName: res.fileName, + unitPrice: res.data.cost.total, + unitTime: res.data.print_time_seconds, + unitWeight: res.data.material_grams, + quantity: res.originalQty // Use the requested quantity + }); + } + }); + + if (items.length === 0) { observer.error('All calculations failed.'); return; } - let totalPrice = 0; + // Initial Aggregation + let grandTotal = setupCost; 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; + + items.forEach(item => { + grandTotal += item.unitPrice * item.quantity; + totalTime += item.unitTime * item.quantity; + totalWeight += item.unitWeight * item.quantity; }); - 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, + items, + setupCost, currency: 'CHF', - printTimeHours: totalHours, - printTimeMinutes: totalMinutes, - materialUsageGrams: Math.ceil(totalWeight), - setupCost + totalPrice: Math.round(grandTotal * 100) / 100, + totalTimeHours: totalHours, + totalTimeMinutes: totalMinutes, + totalWeight: Math.ceil(totalWeight) }; - observer.next(result); // Emit final object + observer.next(result); observer.complete(); } } }, error: (err) => { console.error('Error in request', err); - finalResponses[index] = { success: false }; completedRequests++; - if (completedRequests === total) { + if (completedRequests === totalItems) { observer.error('Requests failed'); } }