feat(web): fix col
This commit is contained in:
27
backend/Dockerfile.dev
Normal file
27
backend/Dockerfile.dev
Normal 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"]
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record CalculationItem(
|
||||
BigDecimal unitPrice,
|
||||
int printTimeSeconds,
|
||||
double materialGrams,
|
||||
int quantity
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CalculationRequest(
|
||||
List<CalculationItem> items
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record CalculationResult(
|
||||
BigDecimal totalPrice,
|
||||
String currency,
|
||||
long totalPrintTimeSeconds,
|
||||
double totalMaterialGrams
|
||||
) {}
|
||||
@@ -54,18 +54,17 @@ public class SlicerService {
|
||||
mapper.writeValue(pFile, processProfile);
|
||||
|
||||
// 3. Build Command
|
||||
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
|
||||
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
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(fFile.getAbsolutePath());
|
||||
command.add("--ensure-on-bed");
|
||||
command.add("--arrange");
|
||||
command.add("1"); // force arrange
|
||||
// command.add("1"); // force arrange - Flag doesn't take value typically
|
||||
command.add("--slice");
|
||||
command.add("0"); // slice plate 0
|
||||
command.add("--outputdir");
|
||||
|
||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal 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
|
||||
@@ -69,7 +69,13 @@
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.development.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||
import { UploadFormComponent } from './components/upload-form/upload-form.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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calculator-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
|
||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, FormsModule],
|
||||
template: `
|
||||
<div class="container hero">
|
||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||
@@ -43,10 +44,31 @@ import { Router } from '@angular/router';
|
||||
(submitRequest)="onCalculate($event)"
|
||||
></app-upload-form>
|
||||
</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>
|
||||
|
||||
<!-- Right Column: Result or Info -->
|
||||
<div class="col-result">
|
||||
<div class="col-result centered-col">
|
||||
@if (error()) {
|
||||
<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: [`
|
||||
.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; }
|
||||
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-8);
|
||||
align-items: start;
|
||||
@media(min-width: 768px) {
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.centered-col {
|
||||
align-self: flex-start; /* Default */
|
||||
@media(min-width: 768px) {
|
||||
align-self: center;
|
||||
}
|
||||
position: sticky;
|
||||
top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Mode Selector (Segmented Control style) */
|
||||
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
background-color: var(--color-neutral-100);
|
||||
@@ -104,7 +124,7 @@ import { Router } from '@angular/router';
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.mode-option {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -116,9 +136,9 @@ import { Router } from '@angular/router';
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-brand);
|
||||
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; }
|
||||
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px; /* Match typical result height */
|
||||
}
|
||||
|
||||
|
||||
.loader-content {
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.loading-title {
|
||||
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);
|
||||
@@ -164,17 +184,83 @@ import { Router } from '@angular/router';
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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 {
|
||||
mode = signal<any>('easy');
|
||||
loading = signal(false);
|
||||
uploadProgress = signal(0);
|
||||
items = signal<QuoteItem[]>([]);
|
||||
result = signal<QuoteResult | null>(null);
|
||||
error = signal<boolean>(false);
|
||||
|
||||
@@ -185,17 +271,21 @@ export class CalculatorPageComponent {
|
||||
this.loading.set(true);
|
||||
this.uploadProgress.set(0);
|
||||
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({
|
||||
next: (event) => {
|
||||
if (typeof event === 'number') {
|
||||
this.uploadProgress.set(event);
|
||||
} else {
|
||||
// It's the result
|
||||
this.result.set(event as QuoteResult);
|
||||
// It's the QuoteItem (unit stats)
|
||||
const newItem = event as QuoteItem;
|
||||
// Append file if it was successful
|
||||
this.items.update(current => [...current, newItem]);
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
|
||||
this.recalculateTotal();
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
||||
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
|
||||
if (this.items().length === 0) return;
|
||||
|
||||
let details = `Richiesta Preventivo Multi-File:\n\n`;
|
||||
|
||||
this.items().forEach((item, index) => {
|
||||
details += `File ${index + 1}: ${item.file.name}\n`;
|
||||
details += `- Quantità: ${item.quantity}\n`;
|
||||
// Additional info can be added here
|
||||
details += `\n`;
|
||||
});
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,21 @@ export interface QuoteResult {
|
||||
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 {
|
||||
success: boolean;
|
||||
data: {
|
||||
@@ -44,194 +59,100 @@ interface BackendResponse {
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
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.
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteItem> {
|
||||
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 => {
|
||||
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 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);
|
||||
|
||||
this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
|
||||
headers,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).subscribe({
|
||||
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 {
|
||||
const m = mat.toUpperCase();
|
||||
if (m.includes('PLA')) return 'pla_basic';
|
||||
|
||||
5
frontend/src/environments/environment.development.ts
Normal file
5
frontend/src/environments/environment.development.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
basicAuth: ''
|
||||
};
|
||||
Reference in New Issue
Block a user