2 Commits

Author SHA1 Message Date
44d99b0a68 feat(web): new step for user details
Some checks failed
Build, Test and Deploy / test-backend (push) Has been cancelled
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-09 17:53:43 +01:00
83b3008234 feat(web): new step for user details 2026-02-09 17:52:34 +01:00
2 changed files with 188 additions and 70 deletions

View File

@@ -1,64 +1,79 @@
<div class="container hero"> <div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p> <p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (orderSuccess()) {
<app-alert type="success">{{ 'USER_DETAILS.ORDER_SUCCESS' | translate }}</app-alert>
}
@if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
}
</div> </div>
<div class="container content-grid"> @if (step() === 'details' && result()) {
<!-- Left Column: Input --> <div class="container">
<div class="col-input"> <app-user-details
<app-card> [quote]="result()!"
<div class="mode-selector"> (submitOrder)="onSubmitOrder($event)"
<div class="mode-option" (cancel)="onCancelDetails()">
[class.active]="mode() === 'easy'" </app-user-details>
(click)="mode.set('easy')"> </div>
{{ 'CALC.MODE_EASY' | translate }} } @else {
</div> <div class="container content-grid">
<div class="mode-option" <!-- Left Column: Input -->
[class.active]="mode() === 'advanced'" <div class="col-input">
(click)="mode.set('advanced')"> <app-card>
{{ 'CALC.MODE_ADVANCED' | translate }} <div class="mode-selector">
</div> <div class="mode-option"
</div> [class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
<app-upload-form {{ 'CALC.MODE_EASY' | translate }}
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (error()) {
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
}
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
</div> </div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
</div>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card> </app-card>
} @else if (result()) { </div>
<app-quote-result
[result]="result()!" <!-- Right Column: Result or Info -->
(consult)="onConsult()" <div class="col-result" #resultCol>
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)"
></app-quote-result> @if (loading()) {
} @else { <app-card class="loading-state">
<app-card> <div class="loader-content">
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3> <div class="spinner"></div>
<ul class="benefits"> <h3 class="loading-title">Analisi in corso...</h3>
<li>{{ 'CALC.BENEFITS_1' | translate }}</li> <p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li> </div>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li> </app-card>
</ul> } @else if (result()) {
</app-card> <app-quote-result
} [result]="result()!"
</div> (consult)="onConsult()"
</div> (proceed)="onProceed()"
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)"
></app-quote-result>
} @else {
<app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
<ul class="benefits">
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
</ul>
</app-card>
}
</div>
</div>
}

View File

@@ -1,6 +1,6 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
@@ -9,7 +9,6 @@ export interface QuoteRequest {
material: string; material: string;
quality: string; quality: string;
notes?: string; notes?: string;
// color removed from global scope
infillDensity?: number; infillDensity?: number;
infillPattern?: string; infillPattern?: string;
supportEnabled?: boolean; supportEnabled?: boolean;
@@ -26,32 +25,133 @@ export interface QuoteItem {
quantity: number; quantity: number;
material?: string; material?: string;
color?: string; color?: string;
// Computed values for UI convenience (optional, can be done in component)
} }
export interface QuoteResult { export interface QuoteResult {
items: QuoteItem[]; items: QuoteItem[];
setupCost: number; setupCost: number;
currency: string; currency: string;
// The following are aggregations that can be re-calculated
totalPrice: number; totalPrice: number;
totalTimeHours: number; totalTimeHours: number;
totalTimeMinutes: number; totalTimeMinutes: number;
totalWeight: 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<number | QuoteResult> {
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<BackendResponse>(`${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) => { finalResponses.forEach((res, idx) => {
if (res && res.success) { if (res && res.success) {
// Find original item to get color const originalItem = request.items[idx];
const originalItem = request.items[idx];
// Note: responses and request.items are index-aligned because we mapped them
items.push({ items.push({
fileName: res.fileName, fileName: res.fileName,
unitPrice: res.data.cost.total, unitPrice: res.data.cost.total,
unitTime: res.data.print_time_seconds, unitTime: res.data.print_time_seconds,
unitWeight: res.data.material_grams, unitWeight: res.data.material_grams,
quantity: res.originalQty, quantity: res.originalQty, // Use the requested quantity
material: request.material, material: request.material,
color: originalItem.color || 'Default' color: originalItem.color || 'Default'
}); });
@@ -59,6 +159,8 @@ export interface QuoteResult {
}); });
if (items.length === 0) { if (items.length === 0) {
// If at least one failed? Or all?
// For now if NO items succeeded, error.
observer.error('All calculations failed.'); observer.error('All calculations failed.');
return; return;
} }
@@ -93,9 +195,10 @@ export interface QuoteResult {
} }
}, },
error: (err) => { 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++; completedRequests++;
if (completedRequests === totalItems) { if (completedRequests === totalItems) {
observer.error('Requests failed'); observer.error('Requests failed');
} }
} }