feat(back-end and front-end): calculator improvements
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m0s

This commit is contained in:
2026-03-05 18:30:37 +01:00
parent 93b0b55f43
commit 235fe7780d
41 changed files with 3503 additions and 1280 deletions

View File

@@ -193,17 +193,22 @@
</p>
<p class="item-meta">
Qta: {{ item.quantity }} | Materiale:
{{ item.materialCode || "-" }} | Colore:
{{ getItemMaterialLabel(item) }} | Colore:
<span
class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
*ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ item.colorCode || "-" }}</span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti:
{{ item.supportsEnabled ? "Sì" : "No" }}
{{ formatSupports(item.supportsEnabled) }}
| Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p>
@@ -283,10 +288,11 @@
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
{{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm
{{ getItemMaterialLabel(item) }} | Colore:
{{ getItemColorLabel(item) }} | {{ item.nozzleDiameterMm ?? "-" }} mm
| {{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
{{ formatSupportsState(item.supportsEnabled) }}
</span>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
AdminOrder,
AdminOrderItem,
AdminOrdersService,
} from '../services/admin-orders.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@@ -273,6 +274,68 @@ export class AdminDashboardComponent implements OnInit {
);
}
getItemMaterialLabel(item: AdminOrderItem): string {
const variantName = (item.filamentVariantDisplayName || '').trim();
const materialCode = (item.materialCode || '').trim();
if (!variantName) {
return materialCode || '-';
}
if (!materialCode) {
return variantName;
}
const normalizedVariant = variantName.toLowerCase();
const normalizedCode = materialCode.toLowerCase();
return normalizedVariant.includes(normalizedCode)
? variantName
: `${variantName} (${materialCode})`;
}
getItemColorLabel(item: AdminOrderItem): string {
const colorName = (item.filamentColorName || '').trim();
const colorCode = (item.colorCode || '').trim();
return colorName || colorCode || '-';
}
getItemColorHex(item: AdminOrderItem): string | null {
const variantHex = (item.filamentColorHex || '').trim();
if (this.isHexColor(variantHex)) {
return variantHex;
}
const code = (item.colorCode || '').trim();
if (this.isHexColor(code)) {
return code;
}
return null;
}
getItemColorCodeSuffix(item: AdminOrderItem): string | null {
const colorHex = this.getItemColorHex(item);
if (!colorHex) {
return null;
}
return colorHex === this.getItemColorLabel(item) ? null : colorHex;
}
formatSupports(value?: boolean): string {
if (value === true) {
return 'Sì';
}
if (value === false) {
return 'No';
}
return '-';
}
formatSupportsState(value?: boolean): string {
if (value === true) {
return 'Supporti ON';
}
if (value === false) {
return 'Supporti OFF';
}
return 'Supporti -';
}
isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId;
}

View File

@@ -8,6 +8,10 @@ export interface AdminOrderItem {
originalFilename: string;
materialCode: string;
colorCode: string;
filamentVariantId?: number;
filamentVariantDisplayName?: string;
filamentColorName?: string;
filamentColorHex?: string;
quality?: string;
nozzleDiameterMm?: number;
layerHeightMm?: number;

View File

@@ -5,11 +5,11 @@
<div class="result-grid">
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[label]="'CHECKOUT.SUBTOTAL' | translate"
[large]="true"
[highlight]="true"
>
{{ totals().price | currency: result().currency }}
{{ costBreakdown().subtotal | currency: result().currency }}
</app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate">
@@ -22,18 +22,6 @@
</div>
<div class="setup-note">
<small>{{
"CALC.SETUP_NOTE"
| translate
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
@if ((result().cadTotal || 0) > 0) {
<small class="shipping-note" style="color: #666">
Servizio CAD: {{ result().cadTotal | currency: result().currency }}
</small>
<br />
}
<small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate
}}</small>
@@ -63,7 +51,7 @@
<span class="file-details">
{{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g |
<span class="material-chip">{{ item.material || "N/D" }}</span>
{{ item.material || "N/D" }}
@if (getItemDifferenceLabel(item.fileName, item.material)) {
|
<small class="item-settings-diff">
@@ -108,6 +96,25 @@
}
</div>
<div class="cost-breakdown">
<div class="cost-row">
<span>Costo di Avvio</span>
<span>{{ costBreakdown().baseSetup | currency: result().currency }}</span>
</div>
@if (costBreakdown().nozzleChange > 0) {
<div class="cost-row">
<span>Cambio Ugello</span>
<span>{{
costBreakdown().nozzleChange | currency: result().currency
}}</span>
</div>
}
<div class="cost-total">
<span>Totale</span>
<span>{{ costBreakdown().total | currency: result().currency }}</span>
</div>
</div>
<div class="actions">
<div class="actions-left">
<app-button variant="secondary" (click)="consult.emit()">

View File

@@ -55,19 +55,6 @@
color: var(--color-text-muted);
}
.material-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid #d9d4bd;
background: #fbf7e9;
color: #6d5b1d;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.2px;
}
.item-controls {
display: flex;
align-items: center;
@@ -218,3 +205,35 @@
color: #6f5b1a;
font-size: 0.9rem;
}
.cost-breakdown {
margin-top: var(--space-2);
margin-bottom: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-100);
}
.cost-row,
.cost-total {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.cost-row {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: var(--space-2);
}
.cost-total {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 2px solid var(--color-border);
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}

View File

@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteResultComponent } from './quote-result.component';
import { QuoteResult } from '../../services/quote-estimator.service';
@@ -38,7 +39,11 @@ describe('QuoteResultComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuoteResultComponent, TranslateModule.forRoot()],
imports: [
QuoteResultComponent,
TranslateModule.forRoot(),
HttpClientTestingModule,
],
}).compileComponents();
fixture = TestBed.createComponent(QuoteResultComponent);

View File

@@ -134,17 +134,37 @@ export class QuoteResultComponent implements OnDestroy {
this.items().some((item) => item.quantity > this.directOrderLimit),
);
totals = computed(() => {
costBreakdown = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
const cad = this.result().cadTotal || 0;
let price = setup + cad;
let subtotal = cad;
currentItems.forEach((item) => {
subtotal += item.unitPrice * item.quantity;
});
const nozzleChange = Math.max(0, this.result().nozzleChangeCost || 0);
const baseSetupRaw =
this.result().baseSetupCost != null
? this.result().baseSetupCost
: this.result().setupCost - nozzleChange;
const baseSetup = Math.max(0, baseSetupRaw || 0);
const total = subtotal + baseSetup + nozzleChange;
return {
subtotal: Math.round(subtotal * 100) / 100,
baseSetup: Math.round(baseSetup * 100) / 100,
nozzleChange: Math.round(nozzleChange * 100) / 100,
total: Math.round(total * 100) / 100,
};
});
totals = computed(() => {
const currentItems = this.items();
let time = 0;
let weight = 0;
currentItems.forEach((i) => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
@@ -153,7 +173,7 @@ export class QuoteResultComponent implements OnDestroy {
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
price: this.costBreakdown().total,
hours,
minutes,
weight: Math.ceil(weight),

View File

@@ -107,7 +107,27 @@
>.
</p>
@if (mode() === "advanced") {
@if (mode() === "easy") {
<div class="easy-global-controls">
<label class="easy-global-field">
<span>{{ "CALC.MATERIAL" | translate }}</span>
<select formControlName="material">
@for (mat of materials(); track mat.value) {
<option [value]="mat.value">{{ mat.label }}</option>
}
</select>
</label>
<label class="easy-global-field">
<span>{{ "CALC.QUALITY" | translate }}</span>
<select formControlName="quality">
@for (quality of qualities(); track quality.value) {
<option [value]="quality.value">{{ quality.label }}</option>
}
</select>
</label>
</div>
} @else {
<div class="sync-settings">
<label class="sync-settings-toggle">
<input

View File

@@ -194,6 +194,48 @@
}
}
.easy-global-controls {
margin-top: var(--space-4);
margin-bottom: var(--space-1);
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.easy-global-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
span {
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--color-text-muted);
}
select {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.55rem 0.75rem;
background: var(--color-bg-card);
font-size: 0.96rem;
font-weight: 600;
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25);
}
}
}
.sync-settings {
margin-top: var(--space-4);
margin-bottom: var(--space-4);

View File

@@ -51,6 +51,8 @@ export interface QuoteItem {
export interface QuoteResult {
sessionId?: string;
items: QuoteItem[];
baseSetupCost?: number;
nozzleChangeCost?: number;
setupCost: number;
globalMachineCost: number;
cadHours?: number;
@@ -382,9 +384,11 @@ export class QuoteEstimatorService {
);
const grandTotal = Number(sessionData?.grandTotalChf);
const effectiveSetupCost =
Number(sessionData?.setupCostChf ?? session?.setupCostChf ?? 0);
const fallbackTotal =
Number(sessionData?.itemsTotalChf || 0) +
Number(session?.setupCostChf || 0) +
effectiveSetupCost +
Number(sessionData?.shippingCostChf || 0);
return {
@@ -411,7 +415,11 @@ export class QuoteEstimatorService {
? Number(item.nozzleDiameterMm)
: undefined,
})),
setupCost: Number(session?.setupCostChf || 0),
baseSetupCost: Number(
sessionData?.baseSetupCostChf ?? session?.setupCostChf ?? 0,
),
nozzleChangeCost: Number(sessionData?.nozzleChangeCostChf ?? 0),
setupCost: effectiveSetupCost,
globalMachineCost: Number(sessionData?.globalMachineCostChf || 0),
cadHours: Number(session?.cadHours || 0),
cadTotal: Number(sessionData?.cadTotalChf || 0),

View File

@@ -249,11 +249,13 @@
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ itemMaterial(item) }}
</span>
<span
*ngIf="item.colorCode"
class="color-dot"
[style.background-color]="item.colorCode"
></span>
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
<span
class="color-dot"
[style.background-color]="itemColorSwatch(item)"
></span>
<span class="color-name">{{ itemColorLabel(item) }}</span>
</span>
</div>
<div class="item-specs-sub">
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
@@ -328,7 +330,14 @@
</div>
<div class="total-row">
<span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span>
<span>{{ session.session.setupCostChf | currency: "CHF" }}</span>
<span>{{
(session.baseSetupCostChf ?? session.session.setupCostChf)
| currency: "CHF"
}}</span>
</div>
<div class="total-row" *ngIf="(session.nozzleChangeCostChf || 0) > 0">
<span>Cambio Ugello</span>
<span>{{ session.nozzleChangeCostChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ "CHECKOUT.SHIPPING" | translate }}</span>

View File

@@ -230,12 +230,24 @@ app-toggle-selector.user-type-selector-compact {
font-size: 0.85rem;
color: var(--color-text-muted);
.item-color {
display: inline-flex;
align-items: center;
gap: 6px;
}
.color-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--color-border);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.color-name {
font-weight: 500;
color: var(--color-text-muted);
}
}

View File

@@ -18,6 +18,7 @@ import {
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
import { getColorHex } from '../../core/constants/colors.const';
@Component({
selector: 'app-checkout',
@@ -55,6 +56,8 @@ export class CheckoutComponent implements OnInit {
selectedPreviewFile = signal<File | null>(null);
selectedPreviewName = signal('');
selectedPreviewColor = signal('#c9ced6');
private variantHexById = new Map<number, string>();
private variantHexByColorName = new Map<string, string>();
userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
@@ -128,6 +131,8 @@ export class CheckoutComponent implements OnInit {
}
ngOnInit(): void {
this.loadMaterialColorPalette();
this.route.queryParams.subscribe((params) => {
this.sessionId = params['session'];
if (!this.sessionId) {
@@ -212,8 +217,40 @@ export class CheckoutComponent implements OnInit {
}
previewColor(item: any): string {
return this.itemColorSwatch(item);
}
itemColorLabel(item: any): string {
const raw = String(item?.colorCode ?? '').trim();
return raw || '#c9ced6';
return raw || '-';
}
itemColorSwatch(item: any): string {
const variantId = Number(item?.filamentVariantId);
if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) {
return this.variantHexById.get(variantId)!;
}
const raw = String(item?.colorCode ?? '').trim();
if (!raw) {
return '#c9ced6';
}
if (this.isHexColor(raw)) {
return raw;
}
const byName = this.variantHexByColorName.get(raw.toLowerCase());
if (byName) {
return byName;
}
const fallback = getColorHex(raw);
if (fallback && fallback !== '#facf0a') {
return fallback;
}
return '#c9ced6';
}
isPreviewLoading(item: any): boolean {
@@ -250,6 +287,41 @@ export class CheckoutComponent implements OnInit {
this.selectedPreviewColor.set('#c9ced6');
}
private loadMaterialColorPalette(): void {
this.quoteService.getOptions().subscribe({
next: (options) => {
this.variantHexById.clear();
this.variantHexByColorName.clear();
for (const material of options?.materials || []) {
for (const variant of material?.variants || []) {
const variantId = Number(variant?.id);
const colorHex = String(variant?.hexColor || '').trim();
const colorName = String(variant?.colorName || '').trim();
if (Number.isFinite(variantId) && colorHex) {
this.variantHexById.set(variantId, colorHex);
}
if (colorName && colorHex) {
this.variantHexByColorName.set(colorName.toLowerCase(), colorHex);
}
}
}
},
error: () => {
this.variantHexById.clear();
this.variantHexByColorName.clear();
},
});
}
private isHexColor(value?: string): boolean {
return (
typeof value === 'string' &&
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
);
}
private loadStlPreviews(session: any): void {
if (
!this.sessionId ||