feat(web) improvements in ui for calculator
This commit is contained in:
@@ -39,13 +39,14 @@ import { Router } from '@angular/router';
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Result or Info -->
|
<!-- Right Column: Result or Info -->
|
||||||
<div class="col-result">
|
<div class="col-result centered-col">
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
|
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
|
||||||
}
|
}
|
||||||
@@ -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 (Segmented Control style) */
|
||||||
.mode-selector {
|
.mode-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -166,21 +174,29 @@ import { Router } from '@angular/router';
|
|||||||
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, private router: Router) {}
|
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
||||||
|
|
||||||
onCalculate(req: QuoteRequest) {
|
onCalculate(req: QuoteRequest) {
|
||||||
this.currentRequest = req; // Store request for consultation
|
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);
|
||||||
|
|||||||
@@ -102,13 +102,12 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<!-- Progress Bar (Only when loading) -->
|
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
||||||
@if (loading()) {
|
@if (loading() && uploadProgress() < 100) {
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill"></div>
|
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <p class="progress-text">Uploading & Analyzing...</p> -->
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +115,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="form.invalid || loading()"
|
[disabled]="form.invalid || loading()"
|
||||||
[fullWidth]="true">
|
[fullWidth]="true">
|
||||||
{{ loading() ? 'Processing...' : ('CALC.CALCULATE' | translate) }}
|
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -205,20 +204,15 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user