feat(front-end): gestione quantità massima e price for piece
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.model.ModelDimensions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SlicerServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseModelDimensionsFromInfoOutput_validOutput_returnsDimensions() {
|
||||||
|
String output = """
|
||||||
|
[file.stl]
|
||||||
|
size_x = 130.860428
|
||||||
|
size_y = 225.000000
|
||||||
|
size_z = 140.000000
|
||||||
|
min_x = 0.000000
|
||||||
|
""";
|
||||||
|
|
||||||
|
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
|
||||||
|
|
||||||
|
assertTrue(dimensions.isPresent());
|
||||||
|
assertEquals(130.860428, dimensions.get().xMm(), 0.000001);
|
||||||
|
assertEquals(225.0, dimensions.get().yMm(), 0.000001);
|
||||||
|
assertEquals(140.0, dimensions.get().zMm(), 0.000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseModelDimensionsFromInfoOutput_withNoise_returnsDimensions() {
|
||||||
|
String output = """
|
||||||
|
[2026-02-27 10:26:30.306251] [0x1] [trace] Initializing StaticPrintConfigs
|
||||||
|
[model.3mf]
|
||||||
|
size_x = 97.909241
|
||||||
|
size_y = 97.909241
|
||||||
|
size_z = 70.000008
|
||||||
|
[2026-02-27 10:26:30.314575] [0x1] [error] calc_exclude_triangles
|
||||||
|
""";
|
||||||
|
|
||||||
|
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
|
||||||
|
|
||||||
|
assertTrue(dimensions.isPresent());
|
||||||
|
assertEquals(97.909241, dimensions.get().xMm(), 0.000001);
|
||||||
|
assertEquals(97.909241, dimensions.get().yMm(), 0.000001);
|
||||||
|
assertEquals(70.000008, dimensions.get().zMm(), 0.000001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseModelDimensionsFromInfoOutput_missingValues_returnsEmpty() {
|
||||||
|
String output = """
|
||||||
|
[model.step]
|
||||||
|
size_x = 10.0
|
||||||
|
size_y = 20.0
|
||||||
|
""";
|
||||||
|
|
||||||
|
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
|
||||||
|
|
||||||
|
assertTrue(dimensions.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,12 +51,21 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
[max]="maxInputQuantity"
|
||||||
[ngModel]="item.quantity"
|
[ngModel]="item.quantity"
|
||||||
(ngModelChange)="updateQuantity(i, $event)"
|
(ngModelChange)="updateQuantity(i, $event)"
|
||||||
class="qty-input">
|
class="qty-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="item-price">
|
<div class="item-price">
|
||||||
|
<span class="item-total-price">
|
||||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||||
|
</span>
|
||||||
|
<small class="item-unit-price" *ngIf="item.quantity > 1; else unitPricePlaceholder">
|
||||||
|
{{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||||
|
</small>
|
||||||
|
<ng-template #unitPricePlaceholder>
|
||||||
|
<small class="item-unit-price item-unit-price--placeholder"> </small>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,8 +77,12 @@
|
|||||||
{{ 'QUOTE.CONSULT' | translate }}
|
{{ 'QUOTE.CONSULT' | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
|
@if (!hasQuantityOverLimit()) {
|
||||||
<app-button (click)="proceed.emit()">
|
<app-button (click)="proceed.emit()">
|
||||||
{{ 'QUOTE.PROCEED_ORDER' | translate }}
|
{{ 'QUOTE.PROCEED_ORDER' | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
} @else {
|
||||||
|
<small class="limit-note">{{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }}</small>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|||||||
@@ -60,6 +60,27 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-total-price {
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-unit-price {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-unit-price--placeholder {
|
||||||
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-grid {
|
.result-grid {
|
||||||
@@ -84,6 +105,13 @@
|
|||||||
|
|
||||||
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
|
||||||
|
.limit-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: calc(var(--space-2) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
.notes-section {
|
.notes-section {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
|||||||
styleUrl: './quote-result.component.scss'
|
styleUrl: './quote-result.component.scss'
|
||||||
})
|
})
|
||||||
export class QuoteResultComponent {
|
export class QuoteResultComponent {
|
||||||
|
readonly maxInputQuantity = 500;
|
||||||
|
readonly directOrderLimit = 100;
|
||||||
|
|
||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
consult = output<void>();
|
consult = output<void>();
|
||||||
proceed = output<void>();
|
proceed = output<void>();
|
||||||
@@ -34,20 +37,23 @@ export class QuoteResultComponent {
|
|||||||
updateQuantity(index: number, newQty: number | string) {
|
updateQuantity(index: number, newQty: number | string) {
|
||||||
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
|
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
|
||||||
if (qty < 1 || isNaN(qty)) return;
|
if (qty < 1 || isNaN(qty)) return;
|
||||||
|
const normalizedQty = Math.min(qty, this.maxInputQuantity);
|
||||||
|
|
||||||
this.items.update(current => {
|
this.items.update(current => {
|
||||||
const updated = [...current];
|
const updated = [...current];
|
||||||
updated[index] = { ...updated[index], quantity: qty };
|
updated[index] = { ...updated[index], quantity: normalizedQty };
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.itemChange.emit({
|
this.itemChange.emit({
|
||||||
id: this.items()[index].id,
|
id: this.items()[index].id,
|
||||||
fileName: this.items()[index].fileName,
|
fileName: this.items()[index].fileName,
|
||||||
quantity: qty
|
quantity: normalizedQty
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit));
|
||||||
|
|
||||||
totals = computed(() => {
|
totals = computed(() => {
|
||||||
const currentItems = this.items();
|
const currentItems = this.items();
|
||||||
const setup = this.result().setupCost;
|
const setup = this.result().setupCost;
|
||||||
|
|||||||
@@ -122,10 +122,15 @@
|
|||||||
<div class="summary-item" *ngFor="let item of quote()!.items">
|
<div class="summary-item" *ngFor="let item of quote()!.items">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="item-name">{{ item.fileName }}</span>
|
<span class="item-name">{{ item.fileName }}</span>
|
||||||
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span>
|
<span class="item-meta">{{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-qty">x{{ item.quantity }}</div>
|
<div class="item-qty">x{{ item.quantity }}</div>
|
||||||
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div>
|
<div class="item-price">
|
||||||
|
<span class="item-total-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</span>
|
||||||
|
<small class="item-unit-price" *ngIf="item.quantity > 1">
|
||||||
|
{{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
@@ -112,6 +112,17 @@
|
|||||||
|
|
||||||
.item-price {
|
.item-price {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-unit-price {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="checkout-form-section">
|
<div class="checkout-form-section">
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<div *ngIf="error" class="error-message">
|
<div *ngIf="error" class="error-message">
|
||||||
{{ error }}
|
{{ error | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
||||||
@@ -142,7 +142,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-price">
|
<div class="item-price">
|
||||||
|
<span class="item-total-price">
|
||||||
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
|
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
|
||||||
|
</span>
|
||||||
|
<small class="item-unit-price" *ngIf="item.quantity > 1">
|
||||||
|
{{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,6 +240,17 @@ app-toggle-selector.user-type-selector-compact {
|
|||||||
margin-left: var(--space-3);
|
margin-left: var(--space-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.item-unit-price {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"QUOTE": {
|
"QUOTE": {
|
||||||
"CONSULT": "Request Consultation",
|
"CONSULT": "Request Consultation",
|
||||||
"PROCEED_ORDER": "Proceed to Order",
|
"PROCEED_ORDER": "Proceed to Order",
|
||||||
"TOTAL": "Total Estimate"
|
"TOTAL": "Total Estimate",
|
||||||
|
"MAX_QTY_NOTICE": "For quantities above {{max}} pieces, request consultation."
|
||||||
},
|
},
|
||||||
"USER_DETAILS": {
|
"USER_DETAILS": {
|
||||||
"TITLE": "Shipping Details",
|
"TITLE": "Shipping Details",
|
||||||
@@ -208,6 +209,7 @@
|
|||||||
"SETUP_FEE": "Setup Fee",
|
"SETUP_FEE": "Setup Fee",
|
||||||
"TOTAL": "Total",
|
"TOTAL": "Total",
|
||||||
"QTY": "Qty",
|
"QTY": "Qty",
|
||||||
|
"PER_PIECE": "per piece",
|
||||||
"SHIPPING": "Shipping"
|
"SHIPPING": "Shipping"
|
||||||
},
|
},
|
||||||
"PAYMENT": {
|
"PAYMENT": {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
"CARD_SHOP_3_TITLE": "Su richiesta",
|
"CARD_SHOP_3_TITLE": "Su richiesta",
|
||||||
"CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.",
|
"CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.",
|
||||||
"SEC_ABOUT_TITLE": "Su di noi",
|
"SEC_ABOUT_TITLE": "Su di noi",
|
||||||
"SEC_ABOUT_TEXT": "Siamo due studenti di ingegneria: la stampa 3D ci ha conquistati per un motivo semplice, vedere un problema e costruire la soluzione. Da questa idea prendono forma prototipi, componenti oggetti pensati per funzionare nella quotidianità. ",
|
"SEC_ABOUT_TEXT": "Siamo due studenti di ingegneria: la stampa 3D ci ha conquistati per un motivo semplice, vedere un problema e costruire la soluzione. Da questa idea prendono forma prototipi, oggetti pensati per funzionare nella quotidianità. ",
|
||||||
"FOUNDERS_PHOTO": "Foto Founders"
|
"FOUNDERS_PHOTO": "Foto Founders"
|
||||||
},
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
@@ -103,13 +103,14 @@
|
|||||||
"PROCESSING": "Elaborazione...",
|
"PROCESSING": "Elaborazione...",
|
||||||
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
||||||
"SETUP_NOTE": "* Include {{cost}} Costo di Setup",
|
"SETUP_NOTE": "* Include {{cost}} Costo di Setup",
|
||||||
"SHIPPING_NOTE": "** costi di spedizione esclusi calcolati al prossimo passaggio",
|
"SHIPPING_NOTE": "** costi di spedizione esclusi, calcolati al prossimo passaggio",
|
||||||
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf"
|
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf"
|
||||||
},
|
},
|
||||||
"QUOTE": {
|
"QUOTE": {
|
||||||
"PROCEED_ORDER": "Procedi con l'ordine",
|
"PROCEED_ORDER": "Procedi con l'ordine",
|
||||||
"CONSULT": "Richiedi Consulenza",
|
"CONSULT": "Richiedi Consulenza",
|
||||||
"TOTAL": "Totale"
|
"TOTAL": "Totale",
|
||||||
|
"MAX_QTY_NOTICE": "Per quantità oltre {{max}} pezzi, richiedi consulenza."
|
||||||
},
|
},
|
||||||
"USER_DETAILS": {
|
"USER_DETAILS": {
|
||||||
"TITLE": "I tuoi dati",
|
"TITLE": "I tuoi dati",
|
||||||
@@ -153,7 +154,7 @@
|
|||||||
"TITLE": "Chi Siamo",
|
"TITLE": "Chi Siamo",
|
||||||
"EYEBROW": "Laboratorio di stampa 3D",
|
"EYEBROW": "Laboratorio di stampa 3D",
|
||||||
"SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.",
|
"SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.",
|
||||||
"HOW_TEXT": "3D Fab nasce dall’interesse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nOggi trasformiamo bisogni concreti e idee in pezzi stampati, con costi trasparenti.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.",
|
"HOW_TEXT": "3D Fab nasce dall’interesse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.",
|
||||||
"PASSIONS_TITLE": "Le nostre passioni",
|
"PASSIONS_TITLE": "Le nostre passioni",
|
||||||
"PASSION_BIKE_TRIAL": "Bike trial",
|
"PASSION_BIKE_TRIAL": "Bike trial",
|
||||||
"PASSION_MOUNTAIN": "Montagna",
|
"PASSION_MOUNTAIN": "Montagna",
|
||||||
@@ -411,6 +412,7 @@
|
|||||||
"SETUP_FEE": "Costo di Avvio",
|
"SETUP_FEE": "Costo di Avvio",
|
||||||
"TOTAL": "Totale",
|
"TOTAL": "Totale",
|
||||||
"QTY": "Qtà",
|
"QTY": "Qtà",
|
||||||
|
"PER_PIECE": "al pezzo",
|
||||||
"SHIPPING": "Spedizione (CH)",
|
"SHIPPING": "Spedizione (CH)",
|
||||||
"INVALID_EMAIL": "Email non valida",
|
"INVALID_EMAIL": "Email non valida",
|
||||||
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
|
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
|
||||||
|
|||||||
Reference in New Issue
Block a user