5 Commits

Author SHA1 Message Date
53e141f8ad Merge pull request 'dev' (#3) from dev into int
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
Reviewed-on: #3
2026-02-05 15:30:04 +01:00
73ccf8f4de feat(web): fix col
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 15:08:58 +01:00
0b4daed512 feat(web) improvements in ui for calculator
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-05 15:03:18 +01:00
8a7d736aa9 feat(web) improvements in ui for calculator
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 14:57:32 +01:00
ce179cac62 feat(web) linked calculator and contact form
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-05 11:42:48 +01:00
5 changed files with 339 additions and 128 deletions

View File

@@ -7,6 +7,7 @@ import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.c
import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service'; import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'app-calculator-page', selector: 'app-calculator-page',
@@ -38,6 +39,7 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
<app-upload-form <app-upload-form
[mode]="mode()" [mode]="mode()"
[loading]="loading()" [loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
@@ -51,12 +53,14 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
@if (loading()) { @if (loading()) {
<app-card class="loading-state"> <app-card class="loading-state">
<div class="spinner"></div> <div class="loader-content">
<p>Analisi geometria e slicing in corso...</p> <div class="spinner"></div>
<small class="text-muted">Potrebbe richiedere qualche secondo.</small> <h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
</div>
</app-card> </app-card>
} @else if (result()) { } @else if (result()) {
<app-quote-result [result]="result()!"></app-quote-result> <app-quote-result [result]="result()!" (consult)="onConsult()"></app-quote-result>
} @else { } @else {
<app-card> <app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3> <h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
@@ -83,6 +87,13 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
} }
} }
.centered-col {
align-self: flex-start; /* Default */
@media(min-width: 768px) {
align-self: center;
}
}
/* Mode Selector (Segmented Control style) */ /* Mode Selector (Segmented Control style) */
.mode-selector { .mode-selector {
display: flex; display: flex;
@@ -119,19 +130,39 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; } .benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
.loading-state { .loading-state {
text-align: center; display: flex;
padding: var(--space-8); align-items: center;
color: var(--color-text-muted); justify-content: center;
min-height: 300px; /* Match typical result height */
}
.spinner { .loader-content {
border: 3px solid rgba(0, 0, 0, 0.1); text-align: center;
border-left-color: var(--color-brand); max-width: 300px;
border-radius: 50%; margin: 0 auto;
width: 32px; }
height: 32px;
animation: spin 1s linear infinite; .loading-title {
margin: 0 auto var(--space-4); font-size: 1.1rem;
} font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
color: var(--color-text);
}
.loading-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.spinner {
border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto;
} }
@keyframes spin { @keyframes spin {
@@ -143,20 +174,29 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
export class CalculatorPageComponent { export class CalculatorPageComponent {
mode = signal<any>('easy'); mode = signal<any>('easy');
loading = signal(false); loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null); result = signal<QuoteResult | null>(null);
error = signal<boolean>(false); error = signal<boolean>(false);
constructor(private estimator: QuoteEstimatorService) {} constructor(private estimator: QuoteEstimatorService, private router: Router) {}
onCalculate(req: QuoteRequest) { onCalculate(req: QuoteRequest) {
this.currentRequest = req;
this.loading.set(true); this.loading.set(true);
this.uploadProgress.set(0);
this.error.set(false); this.error.set(false);
this.result.set(null); this.result.set(null);
this.estimator.calculate(req).subscribe({ this.estimator.calculate(req).subscribe({
next: (res) => { next: (event) => {
this.result.set(res); if (typeof event === 'number') {
this.loading.set(false); this.uploadProgress.set(event);
} else {
// It's the result
this.result.set(event as QuoteResult);
this.loading.set(false);
this.uploadProgress.set(100);
}
}, },
error: () => { error: () => {
this.error.set(true); this.error.set(true);
@@ -164,4 +204,30 @@ export class CalculatorPageComponent {
} }
}); });
} }
private currentRequest: QuoteRequest | null = null;
onConsult() {
if (!this.currentRequest) return;
const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
details += `- Quantità: ${req.quantity}\n`;
if (req.mode === 'advanced') {
if (req.color) details += `- Colore: ${req.color}\n`;
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
}
if (req.notes) details += `\nNote: ${req.notes}`;
this.estimator.setPendingConsultation({
files: req.files,
message: details
});
this.router.navigate(['/contact']);
}
} }

View File

@@ -34,7 +34,7 @@ import { QuoteResult } from '../../services/quote-estimator.service';
<div class="actions"> <div class="actions">
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button> <app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true">{{ 'CALC.CONSULT' | translate }}</app-button> <app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button>
</div> </div>
</app-card> </app-card>
`, `,
@@ -53,4 +53,5 @@ import { QuoteResult } from '../../services/quote-estimator.service';
}) })
export class QuoteResultComponent { export class QuoteResultComponent {
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>();
} }

View File

@@ -101,25 +101,21 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
></app-input> ></app-input>
} }
@if (loading()) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<p class="progress-text">Uploading & Analyzing...</p>
</div>
}
<div class="actions"> <div class="actions">
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
@if (loading() && uploadProgress() < 100) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
</div>
</div>
}
<app-button <app-button
type="submit" type="submit"
[disabled]="form.invalid || loading()" [disabled]="form.invalid || loading()"
[fullWidth]="true"> [fullWidth]="true">
@if (loading()) { {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
Slicing in progress...
} @else {
{{ 'CALC.CALCULATE' | translate }}
}
</app-button> </app-button>
</div> </div>
</form> </form>
@@ -188,38 +184,35 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
/* Progress Bar */ /* Progress Bar */
.progress-container { .progress-container {
margin-top: var(--space-4); margin-bottom: var(--space-3);
padding: var(--space-4); /* padding: var(--space-2); */
background: var(--color-neutral-100); /* background: var(--color-neutral-100); */
border-radius: var(--radius-md); /* border-radius: var(--radius-md); */
text-align: center; text-align: center;
width: 100%;
} }
.progress-bar { .progress-bar {
height: 6px; height: 4px;
background: var(--color-border); background: var(--color-border);
border-radius: 3px; border-radius: 2px;
overflow: hidden; overflow: hidden;
margin-bottom: var(--space-2); margin-bottom: 0;
position: relative; position: relative;
width: 100%;
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: var(--color-brand); background: var(--color-brand);
width: 0%; 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); } .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 { export class UploadFormComponent {
mode = input<'easy' | 'advanced'>('easy'); mode = input<'easy' | 'advanced'>('easy');
loading = input<boolean>(false); loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>(); submitRequest = output<QuoteRequest>();
form: FormGroup; form: FormGroup;

View File

@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs'; import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators';
@@ -44,80 +44,192 @@ interface BackendResponse {
export class QuoteEstimatorService { export class QuoteEstimatorService {
private http = inject(HttpClient); private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
const requests: Observable<BackendResponse>[] = request.files.map(file => { const formData = new FormData();
const formData = new FormData(); // Assuming single file primarily for now, or aggregating.
formData.append('file', file); // The current UI seems to select one "active" file or handle multiple.
formData.append('machine', 'bambu_a1'); // Hardcoded for now // The logic below was mapping multiple files to multiple requests.
formData.append('filament', this.mapMaterial(request.material)); // To support progress seamlessly for the "main" action, let's focus on the processing flow.
formData.append('quality', this.mapQuality(request.quality)); // 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.
if (request.mode === 'advanced') { // NOTE: The previous logic did `request.files.map(...)`.
if (request.color) formData.append('material_color', request.color); // If we want a global progress, we can mistakenly complexity it.
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); // 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?
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); // "formData.append('file', file)" inside the map implies multiple requests.
if (request.supportEnabled) formData.append('support_enabled', 'true'); // 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.
const headers: any = {}; // Refined approach:
// @ts-ignore // 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.
if (environment.basicAuth) { // BUT, the user wants "la barra di upload".
// @ts-ignore // If we assume standard use case is 1 file, it's easy.
headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); // 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<QuoteResult>` originally, now we need progress.
// Let's change return type to `Observable<any>` or a specific union.
console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`); // Let's handle just the first file for progress visualization simplicity if multiple are present,
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, { headers }).pipe( // or better, create a wrapper that merges the progress.
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( // Actually, looking at the previous code: `const requests = request.files.map(...)`.
map(responses => { // If we have 3 files, we have 3 requests.
console.log('All responses:', responses); // We can emit progress events.
const validResponses = responses.filter(r => r.success); // START implementation for generalized progress:
if (validResponses.length === 0 && responses.length > 0) {
throw new Error('All calculations failed. Check backend connection.');
}
let totalPrice = 0; const file = request.files[0]; // Primary target for now to ensure we have a progress to show.
let totalTime = 0; // Ideally we should upload all.
let totalWeight = 0;
let setupCost = 10; // Base setup
validResponses.forEach(res => { // For this task, to satisfy "bar disappears after upload", we really need to know when upload finishes.
totalPrice += res.data.cost.total;
totalTime += res.data.print_time_seconds; // Let's keep it robust:
totalWeight += res.data.material_grams; // 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.
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 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);
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).pipe(
map(event => ({ file, event })),
catchError(err => of({ file, error: err }))
);
}); });
// Apply quantity multiplier // We process all uploads.
totalPrice = (totalPrice * request.quantity) + setupCost; // We want to emit:
totalWeight = totalWeight * request.quantity; // 1. Progress updates (average of all files?)
// Total time usually parallel if we have multiple printers, but let's sum for now // 2. Final QuoteResult
totalTime = totalTime * request.quantity;
const totalHours = Math.floor(totalTime / 3600); const allProgress: number[] = new Array(request.files.length).fill(0);
const totalMinutes = Math.ceil((totalTime % 3600) / 60); let completedRequests = 0;
const finalResponses: any[] = [];
return { // Subscribe to all
price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals uploads.forEach((obs, index) => {
currency: 'CHF', obs.subscribe({
printTimeHours: totalHours, next: (wrapper: any) => {
printTimeMinutes: totalMinutes, if (wrapper.error) {
materialUsageGrams: Math.ceil(totalWeight), // handled in final calculation
setupCost finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
}; return;
}) }
);
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 { private mapMaterial(mat: string): string {
@@ -134,4 +246,17 @@ export class QuoteEstimatorService {
if (q.includes('high')) return 'extra_fine'; if (q.includes('high')) return 'extra_fine';
return 'standard'; return 'standard';
} }
// Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
setPendingConsultation(data: {files: File[], message: string}) {
this.pendingConsultation.set(data);
}
getPendingConsultation() {
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
}
} }

View File

@@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
interface FilePreview { interface FilePreview {
file: File; file: File;
@@ -242,7 +243,11 @@ export class ContactFormComponent {
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
]; ];
constructor(private fb: FormBuilder, private translate: TranslateService) { constructor(
private fb: FormBuilder,
private translate: TranslateService,
private estimator: QuoteEstimatorService
) {
this.form = this.fb.group({ this.form = this.fb.group({
requestType: ['custom', Validators.required], requestType: ['custom', Validators.required],
name: ['', Validators.required], name: ['', Validators.required],
@@ -279,6 +284,27 @@ export class ContactFormComponent {
companyNameControl?.updateValueAndValidity(); companyNameControl?.updateValueAndValidity();
refPersonControl?.updateValueAndValidity(); refPersonControl?.updateValueAndValidity();
}); });
// Check for pending consultation data
effect(() => {
// Use timeout or run in constructor to ensure dependency availability?
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
});
const pending = this.estimator.getPendingConsultation();
if (pending) {
this.form.patchValue({
requestType: 'consult',
message: pending.message
});
// Process files
const filePreviews: FilePreview[] = [];
pending.files.forEach(f => {
filePreviews.push({ file: f, type: this.getFileType(f) });
});
this.files.set(filePreviews);
}
} }
setCompanyMode(isCompany: boolean) { setCompanyMode(isCompany: boolean) {