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);
|
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
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": {
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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