feat(web): fix col
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s

This commit is contained in:
2026-02-05 15:33:31 +01:00
parent 73ccf8f4de
commit 41aa474cbb
11 changed files with 428 additions and 228 deletions

27
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,27 @@
FROM eclipse-temurin:21-jdk-jammy
# Install system dependencies for OrcaSlicer
RUN apt-get update && apt-get install -y \
wget \
p7zip-full \
libgl1 \
libglib2.0-0 \
libgtk-3-0 \
libdbus-1-3 \
libwebkit2gtk-4.1-0 \
&& rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer
WORKDIR /opt
RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \
&& 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \
&& chmod -R +x /opt/orcaslicer \
&& rm OrcaSlicer.AppImage
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
ENV SLICER_PATH="/opt/orcaslicer/AppRun"
WORKDIR /app
# The command will be overridden by docker-compose, but default is sensible
CMD ["./gradlew", "bootRun"]

View File

@@ -0,0 +1,56 @@
package com.printcalculator.controller;
import com.printcalculator.dto.CalculationItem;
import com.printcalculator.dto.CalculationRequest;
import com.printcalculator.dto.CalculationResult;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@CrossOrigin(origins = "*")
@RequestMapping("/api/quote")
public class CalculationController {
private static final BigDecimal SETUP_COST = new BigDecimal("10.00");
@PostMapping("/calculate-total")
public ResponseEntity<CalculationResult> calculateTotal(@RequestBody CalculationRequest request) {
BigDecimal totalCost = BigDecimal.ZERO;
long totalTime = 0;
double totalMaterial = 0;
if (request.items() != null) {
for (CalculationItem item : request.items()) {
BigDecimal qty = BigDecimal.valueOf(item.quantity());
// Cost: Unit Price * Qty
if (item.unitPrice() != null) {
totalCost = totalCost.add(item.unitPrice().multiply(qty));
}
// Time: Unit Time * Qty
totalTime += (long) item.printTimeSeconds() * item.quantity();
// Material: Unit Weight * Qty
totalMaterial += item.materialGrams() * item.quantity();
}
}
// Add Setup Cost (once per order? or logic? Plan implied once per total)
// QuoteEstimatorService.ts line 201: totalPrice = ... + setupCost;
// The user said "prendi il valore di singola stampa e moltiplicalo per numero di elementi o somma"
// Setup cost is usually once per job.
if (request.items() != null && !request.items().isEmpty()) {
totalCost = totalCost.add(SETUP_COST);
}
return ResponseEntity.ok(new CalculationResult(
totalCost,
"CHF",
totalTime,
totalMaterial
));
}
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public record CalculationItem(
BigDecimal unitPrice,
int printTimeSeconds,
double materialGrams,
int quantity
) {}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.dto;
import java.util.List;
public record CalculationRequest(
List<CalculationItem> items
) {}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public record CalculationResult(
BigDecimal totalPrice,
String currency,
long totalPrintTimeSeconds,
double totalMaterialGrams
) {}

View File

@@ -54,18 +54,17 @@ public class SlicerService {
mapper.writeValue(pFile, processProfile); mapper.writeValue(pFile, processProfile);
// 3. Build Command // 3. Build Command
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add(slicerPath); command.add(slicerPath);
command.add("--load-settings"); command.add("--load-settings");
command.add(settingsArg); command.add(mFile.getAbsolutePath());
command.add("--load-settings");
command.add(pFile.getAbsolutePath());
command.add("--load-filaments"); command.add("--load-filaments");
command.add(fFile.getAbsolutePath()); command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed"); command.add("--ensure-on-bed");
command.add("--arrange"); command.add("--arrange");
command.add("1"); // force arrange // command.add("1"); // force arrange - Flag doesn't take value typically
command.add("--slice"); command.add("--slice");
command.add("0"); // slice plate 0 command.add("0"); // slice plate 0
command.add("--outputdir"); command.add("--outputdir");

40
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
backend:
platform: linux/amd64
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: print-calculator-backend-dev
volumes:
- ./backend:/app
- /app/.gradle # Persist gradle cache to avoid re-downloading
- /app/build # Prevent host/container build artifact conflicts
ports:
- "8000:8000"
environment:
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
- PRINTER_POWER_WATTS=150
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
command: ./gradlew bootRun --no-daemon
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: build # Stop at Node stage, don't go to Nginx
container_name: print-calculator-frontend-dev
volumes:
- ./frontend:/app
- /app/node_modules # Preserve container node_modules
ports:
- "4200:4200" # Use ng serve default port for dev
depends_on:
- backend
# Run ng serve accessible from host
command: npm start -- --host 0.0.0.0 --disable-host-check --poll 2000
restart: unless-stopped

View File

@@ -69,7 +69,13 @@
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
} }
}, },

View File

@@ -1,18 +1,19 @@
import { Component, signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { FormsModule } from '@angular/forms';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
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, QuoteItem } from './services/quote-estimator.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@Component({ @Component({
selector: 'app-calculator-page', selector: 'app-calculator-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent], imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, FormsModule],
template: ` template: `
<div class="container hero"> <div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ 'CALC.TITLE' | translate }}</h1>
@@ -43,10 +44,31 @@ import { Router } from '@angular/router';
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
@if (items().length > 0) {
<div class="items-list-container">
<h3>File Caricati</h3>
@for (item of items(); track $index) {
<app-card class="item-card">
<div class="item-row">
<span class="item-name">{{ item.file.name }}</span>
<div class="item-controls">
<span class="unit-price">{{ item.unitPrice | currency:'CHF' }} / pz</span>
<div class="quantity-control">
<label>Qtà:</label>
<input type="number" min="1" [ngModel]="item.quantity" (ngModelChange)="updateQuantity($index, $event)" class="qty-input">
</div>
<button class="remove-btn" (click)="removeItem($index)">✕</button>
</div>
</div>
</app-card>
}
</div>
}
</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>
} }
@@ -77,24 +99,22 @@ import { Router } from '@angular/router';
styles: [` styles: [`
.hero { padding: var(--space-12) 0; text-align: center; } .hero { padding: var(--space-12) 0; text-align: center; }
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; } .subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-8); gap: var(--space-8);
align-items: start;
@media(min-width: 768px) { @media(min-width: 768px) {
grid-template-columns: 1.5fr 1fr; grid-template-columns: 1.5fr 1fr;
} }
} }
.centered-col { .centered-col {
align-self: flex-start; /* Default */ position: sticky;
@media(min-width: 768px) { top: var(--space-4);
align-self: center;
}
} }
/* Mode Selector (Segmented Control style) */
.mode-selector { .mode-selector {
display: flex; display: flex;
background-color: var(--color-neutral-100); background-color: var(--color-neutral-100);
@@ -104,7 +124,7 @@ import { Router } from '@angular/router';
gap: 4px; gap: 4px;
width: 100%; width: 100%;
} }
.mode-option { .mode-option {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -116,9 +136,9 @@ import { Router } from '@angular/router';
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all 0.2s ease; transition: all 0.2s ease;
user-select: none; user-select: none;
&:hover { color: var(--color-text); } &:hover { color: var(--color-text); }
&.active { &.active {
background-color: var(--color-brand); background-color: var(--color-brand);
color: #000; color: #000;
@@ -128,33 +148,33 @@ import { Router } from '@angular/router';
} }
.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 {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 300px; /* Match typical result height */ min-height: 300px; /* Match typical result height */
} }
.loader-content { .loader-content {
text-align: center; text-align: center;
max-width: 300px; max-width: 300px;
margin: 0 auto; margin: 0 auto;
} }
.loading-title { .loading-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: var(--space-4) 0 var(--space-2); margin: var(--space-4) 0 var(--space-2);
color: var(--color-text); color: var(--color-text);
} }
.loading-text { .loading-text {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-muted); color: var(--color-text-muted);
line-height: 1.5; line-height: 1.5;
} }
.spinner { .spinner {
border: 3px solid var(--color-neutral-200); border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand); border-left-color: var(--color-brand);
@@ -164,17 +184,83 @@ import { Router } from '@angular/router';
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto; margin: 0 auto;
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
.items-list-container {
margin-top: var(--space-6);
}
.items-list-container h3 {
margin-bottom: var(--space-4);
font-size: 1.1rem;
font-weight: 600;
}
.item-card {
margin-bottom: var(--space-4);
display: block;
padding: var(--space-4);
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.item-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
font-size: 0.9rem;
}
.item-controls {
display: flex;
align-items: center;
gap: var(--space-4);
}
.unit-price {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.quantity-control {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.qty-input {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-sm);
text-align: center;
}
.remove-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px;
font-size: 1.1rem;
transition: color 0.2s;
&:hover {
color: var(--color-error);
}
}
`] `]
}) })
export class CalculatorPageComponent { export class CalculatorPageComponent {
mode = signal<any>('easy'); mode = signal<any>('easy');
loading = signal(false); loading = signal(false);
uploadProgress = signal(0); uploadProgress = signal(0);
items = signal<QuoteItem[]>([]);
result = signal<QuoteResult | null>(null); result = signal<QuoteResult | null>(null);
error = signal<boolean>(false); error = signal<boolean>(false);
@@ -185,17 +271,21 @@ export class CalculatorPageComponent {
this.loading.set(true); this.loading.set(true);
this.uploadProgress.set(0); this.uploadProgress.set(0);
this.error.set(false); this.error.set(false);
this.result.set(null); // Don't clear result immediately to prevent flickering if we are just adding
this.estimator.calculate(req).subscribe({ this.estimator.calculate(req).subscribe({
next: (event) => { next: (event) => {
if (typeof event === 'number') { if (typeof event === 'number') {
this.uploadProgress.set(event); this.uploadProgress.set(event);
} else { } else {
// It's the result // It's the QuoteItem (unit stats)
this.result.set(event as QuoteResult); const newItem = event as QuoteItem;
// Append file if it was successful
this.items.update(current => [...current, newItem]);
this.loading.set(false); this.loading.set(false);
this.uploadProgress.set(100); this.uploadProgress.set(100);
this.recalculateTotal();
} }
}, },
error: () => { error: () => {
@@ -205,29 +295,58 @@ export class CalculatorPageComponent {
}); });
} }
updateQuantity(index: number, newQty: number) {
if (newQty < 1) newQty = 1; // Validation
this.items.update(items => {
const newItems = [...items];
newItems[index] = { ...newItems[index], quantity: newQty };
return newItems;
});
// Recalculate cost
this.recalculateTotal();
}
removeItem(index: number) {
this.items.update(items => items.filter((_, i) => i !== index));
if (this.items().length === 0) {
this.result.set(null);
} else {
this.recalculateTotal();
}
}
recalculateTotal() {
if (this.items().length === 0) return;
this.estimator.calculateTotal(this.items()).subscribe({
next: (res) => this.result.set(res),
error: () => this.error.set(true)
});
}
private currentRequest: QuoteRequest | null = null; private currentRequest: QuoteRequest | null = null;
onConsult() { onConsult() {
if (!this.currentRequest) return; if (this.items().length === 0) return;
const req = this.currentRequest; let details = `Richiesta Preventivo Multi-File:\n\n`;
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`; this.items().forEach((item, index) => {
details += `- Qualità: ${req.quality}\n`; details += `File ${index + 1}: ${item.file.name}\n`;
details += `- Quantità: ${req.quantity}\n`; details += `- Quantità: ${item.quantity}\n`;
// Additional info can be added here
if (req.mode === 'advanced') { details += `\n`;
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
}); });
if (this.currentRequest?.notes) {
details += `Note Generali: ${this.currentRequest.notes}`;
}
this.estimator.setPendingConsultation({
files: this.items().map(i => i.file),
message: details
});
this.router.navigate(['/contact']); this.router.navigate(['/contact']);
} }
} }

View File

@@ -26,6 +26,21 @@ export interface QuoteResult {
setupCost: number; setupCost: number;
} }
export interface QuoteItem {
file: File;
unitPrice: number;
printTimeSeconds: number;
materialGrams: number;
quantity: number;
}
export interface CalculationResult {
totalPrice: number;
currency: string;
totalPrintTimeSeconds: number;
totalMaterialGrams: number;
}
interface BackendResponse { interface BackendResponse {
success: boolean; success: boolean;
data: { data: {
@@ -44,194 +59,100 @@ interface BackendResponse {
export class QuoteEstimatorService { export class QuoteEstimatorService {
private http = inject(HttpClient); private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteItem> {
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<QuoteResult>` originally, now we need progress.
// Let's change return type to `Observable<any>` 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(); if (request.files.length === 0) return of();
// We assume the request contains the file to be sliced.
const file = request.files[0];
// 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 => { return new Observable(observer => {
let completed = 0; const formData = new FormData();
let total = request.files.length; formData.append('file', file);
const results: BackendResponse[] = []; formData.append('machine', 'bambu_a1');
let grandTotal = 0; // For progress calculation if we wanted to average formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
// We'll just track the "upload phase" of the bundle.
// Actually, let's just use `concat` or `merge`? if (request.mode === 'advanced') {
// Let's simplify: We will only track progress for the first file or "active" file. if (request.color) formData.append('material_color', request.color);
// But the previous code sent ALL files. if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
// Let's change the return type to emit events. if (request.supportEnabled) formData.append('support_enabled', 'true');
}
const uploads = request.files.map(file => {
const formData = new FormData(); const headers: any = {};
formData.append('file', file); // @ts-ignore
formData.append('machine', 'bambu_a1'); if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality)); this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
if (request.mode === 'advanced') { headers,
if (request.color) formData.append('material_color', request.color); reportProgress: true,
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); observe: 'events'
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); }).subscribe({
if (request.supportEnabled) formData.append('support_enabled', 'true'); next: (event) => {
if (event.type === 1) { // HttpEventType.UploadProgress
if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total);
observer.next(percent);
}
} else if (event.type === 4) { // HttpEventType.Response
const body = event.body;
if (body && body.success) {
const unitPrice = body.data.cost.total;
const unitTime = body.data.print_time_seconds;
const unitWeight = body.data.material_grams;
const result: QuoteItem = {
file: file,
unitPrice: unitPrice,
printTimeSeconds: unitTime,
materialGrams: unitWeight,
quantity: request.quantity
};
observer.next(result);
observer.complete();
} else {
observer.error('Backend returned success=false');
}
}
},
error: (err) => {
console.error('Upload failed', err);
observer.error(err);
} }
});
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 }))
);
});
// 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) => {
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;
}
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');
}
}
});
});
}); });
} }
calculateTotal(items: QuoteItem[]): Observable<QuoteResult> {
const payload = {
items: items.map(i => ({
unitPrice: i.unitPrice,
printTimeSeconds: i.printTimeSeconds,
materialGrams: i.materialGrams,
quantity: i.quantity
}))
};
return this.http.post<CalculationResult>(`${environment.apiUrl}/api/quote/calculate-total`, payload)
.pipe(
map(res => {
const totalTime = res.totalPrintTimeSeconds;
const totalHours = Math.floor(totalTime / 3600);
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
return {
price: res.totalPrice,
currency: res.currency,
printTimeHours: totalHours,
printTimeMinutes: totalMinutes,
materialUsageGrams: Math.ceil(res.totalMaterialGrams),
setupCost: 10 // Backend handles it now, but UI might want to know.
};
})
);
}
private mapMaterial(mat: string): string { private mapMaterial(mat: string): string {
const m = mat.toUpperCase(); const m = mat.toUpperCase();
if (m.includes('PLA')) return 'pla_basic'; if (m.includes('PLA')) return 'pla_basic';

View File

@@ -0,0 +1,5 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8000',
basicAuth: ''
};