feat(front-end): calculator improvements

This commit is contained in:
2026-03-05 15:43:37 +01:00
parent d061f21d79
commit fe3951b6c3
26 changed files with 901 additions and 112 deletions

View File

@@ -192,13 +192,18 @@
<strong>{{ item.originalFilename }}</strong>
</p>
<p class="item-meta">
Qta: {{ item.quantity }} | Colore:
Qta: {{ item.quantity }} | Materiale:
{{ item.materialCode || "-" }} | Colore:
<span
class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
></span>
<span>{{ item.colorCode || "-" }}</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti:
{{ item.supportsEnabled ? "Sì" : "No" }}
| Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p>
@@ -273,17 +278,15 @@
</div>
</div>
<h4>Colori file</h4>
<h4>Parametri per file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
<span
class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
></span>
{{ item.colorCode || "-" }}
{{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm
| {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}%
| {{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
</span>
</div>
</div>

View File

@@ -126,6 +126,7 @@
<th>Qta</th>
<th>Tempo</th>
<th>Materiale</th>
<th>Scelte utente</th>
<th>Stato</th>
<th>Prezzo unit.</th>
</tr>
@@ -142,6 +143,14 @@
: "-"
}}
</td>
<td>
{{ item.materialCode || "-" }} |
{{ item.nozzleDiameterMm ?? "-" }} mm |
{{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% |
{{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
</td>
<td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency: "CHF" }}</td>
</tr>

View File

@@ -127,7 +127,15 @@ export interface AdminQuoteSessionDetailItem {
quantity: number;
printTimeSeconds?: number;
materialGrams?: number;
materialCode?: string;
quality?: string;
nozzleDiameterMm?: number;
layerHeightMm?: number;
infillPercent?: number;
infillPattern?: string;
supportsEnabled?: boolean;
colorCode?: string;
filamentVariantId?: number;
status: string;
unitPriceChf: number;
}

View File

@@ -8,6 +8,12 @@ export interface AdminOrderItem {
originalFilename: string;
materialCode: string;
colorCode: string;
quality?: string;
nozzleDiameterMm?: number;
layerHeightMm?: number;
infillPercent?: number;
infillPattern?: string;
supportsEnabled?: boolean;
quantity: number;
printTimeSeconds: number;
materialGrams: number;

View File

@@ -47,6 +47,7 @@
(submitRequest)="onCalculate($event)"
(itemQuantityChange)="onUploadItemQuantityChange($event)"
(printSettingsChange)="onUploadPrintSettingsChange($event)"
(itemSettingsDiffChange)="onItemSettingsDiffChange($event)"
></app-upload-form>
</app-card>
</div>
@@ -67,6 +68,7 @@
<app-quote-result
[result]="result()!"
[recalculationRequired]="requiresRecalculation()"
[itemSettingsDiffByFileName]="itemSettingsDiffByFileName()"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemQuantityPreviewChange)="onQuoteItemQuantityPreviewChange($event)"

View File

@@ -25,6 +25,17 @@ import { SuccessStateComponent } from '../../shared/components/success-state/suc
import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service';
type TrackedPrintSettings = {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
};
@Component({
selector: 'app-calculator-page',
standalone: true,
@@ -57,16 +68,11 @@ export class CalculatorPageComponent implements OnInit {
orderSuccess = signal(false);
requiresRecalculation = signal(false);
private baselinePrintSettings: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} | null = null;
itemSettingsDiffByFileName = signal<
Record<string, { differences: string[] }>
>({});
private baselinePrintSettings: TrackedPrintSettings | null = null;
private baselineItemSettingsByFileName = new Map<string, TrackedPrintSettings>();
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
@@ -115,7 +121,12 @@ export class CalculatorPageComponent implements OnInit {
this.baselinePrintSettings = this.toTrackedSettingsFromSession(
data.session,
);
this.baselineItemSettingsByFileName = this.buildBaselineMapFromSession(
data.items || [],
this.baselinePrintSettings,
);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
const isCadSession = data?.session?.status === 'CAD_ACTIVE';
this.cadSessionLocked.set(isCadSession);
this.step.set('quote');
@@ -188,23 +199,33 @@ export class CalculatorPageComponent implements OnInit {
});
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
// Assuming index matches.
// Need to be careful if items order changed, but usually ID sort or insert order.
if (item.colorCode) {
this.uploadForm.updateItemColor(index, {
colorName: item.colorCode,
filamentVariantId: item.filamentVariantId,
});
}
items.forEach((item, index) => {
const tracked = this.toTrackedSettingsFromSessionItem(
item,
this.toTrackedSettingsFromSession(session),
);
this.uploadForm.setItemPrintSettingsByIndex(index, {
material: tracked.material.toUpperCase(),
quality: tracked.quality,
nozzleDiameter: tracked.nozzleDiameter,
layerHeight: tracked.layerHeight,
infillDensity: tracked.infillDensity,
infillPattern: tracked.infillPattern,
supportEnabled: tracked.supportEnabled,
});
if (item.colorCode) {
this.uploadForm.updateItemColor(index, {
colorName: item.colorCode,
filamentVariantId: item.filamentVariantId,
});
}
});
const selected = this.uploadForm.selectedFile();
if (selected) {
this.uploadForm.selectFile(selected);
}
}
this.loading.set(false);
},
@@ -254,7 +275,10 @@ export class CalculatorPageComponent implements OnInit {
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req);
this.baselineItemSettingsByFileName =
this.buildBaselineMapFromRequest(req);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
@@ -395,7 +419,12 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('upload');
this.result.set(null);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.baselinePrintSettings = null;
this.baselineItemSettingsByFileName = new Map<
string,
TrackedPrintSettings
>();
this.cadSessionLocked.set(false);
this.orderSuccess.set(false);
this.switchMode('easy'); // Reset to default and sync URL
@@ -403,21 +432,16 @@ export class CalculatorPageComponent implements OnInit {
private currentRequest: QuoteRequest | null = null;
onUploadPrintSettingsChange(settings: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}) {
onUploadPrintSettingsChange(_: TrackedPrintSettings) {
void _;
if (!this.result()) return;
if (!this.baselinePrintSettings) return;
this.requiresRecalculation.set(
!this.sameTrackedSettings(this.baselinePrintSettings, settings),
);
this.refreshRecalculationRequirement();
}
onItemSettingsDiffChange(
diffByFileName: Record<string, { differences: string[] }>,
) {
this.itemSettingsDiffByFileName.set(diffByFileName || {});
}
onConsult() {
@@ -478,7 +502,12 @@ export class CalculatorPageComponent implements OnInit {
this.error.set(true);
this.result.set(null);
this.requiresRecalculation.set(false);
this.itemSettingsDiffByFileName.set({});
this.baselinePrintSettings = null;
this.baselineItemSettingsByFileName = new Map<
string,
TrackedPrintSettings
>();
}
switchMode(nextMode: 'easy' | 'advanced'): void {
@@ -499,16 +528,7 @@ export class CalculatorPageComponent implements OnInit {
});
}
private toTrackedSettingsFromRequest(req: QuoteRequest): {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} {
private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings {
return {
mode: req.mode,
material: this.normalizeString(req.material || 'PLA'),
@@ -521,16 +541,37 @@ export class CalculatorPageComponent implements OnInit {
};
}
private toTrackedSettingsFromSession(session: any): {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
} {
private toTrackedSettingsFromItem(
req: QuoteRequest,
item: QuoteRequest['items'][number],
): TrackedPrintSettings {
return {
mode: req.mode,
material: this.normalizeString(item.material || req.material || 'PLA'),
quality: this.normalizeString(item.quality || req.quality || 'standard'),
nozzleDiameter: this.normalizeNumber(
item.nozzleDiameter ?? req.nozzleDiameter,
0.4,
2,
),
layerHeight: this.normalizeNumber(
item.layerHeight ?? req.layerHeight,
0.2,
3,
),
infillDensity: this.normalizeNumber(
item.infillDensity ?? req.infillDensity,
20,
2,
),
infillPattern: this.normalizeString(
item.infillPattern || req.infillPattern || 'grid',
),
supportEnabled: Boolean(item.supportEnabled ?? req.supportEnabled),
};
}
private toTrackedSettingsFromSession(session: any): TrackedPrintSettings {
const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3);
return {
mode: this.mode(),
@@ -545,27 +586,111 @@ export class CalculatorPageComponent implements OnInit {
};
}
private toTrackedSettingsFromSessionItem(
item: any,
fallback: TrackedPrintSettings,
): TrackedPrintSettings {
const layer = this.normalizeNumber(item?.layerHeightMm, fallback.layerHeight, 3);
return {
mode: this.mode(),
material: this.normalizeString(item?.materialCode || fallback.material),
quality: this.normalizeString(
item?.quality ||
(layer >= 0.24
? 'draft'
: layer <= 0.12
? 'extra_fine'
: 'standard'),
),
nozzleDiameter: this.normalizeNumber(
item?.nozzleDiameterMm,
fallback.nozzleDiameter,
2,
),
layerHeight: layer,
infillDensity: this.normalizeNumber(
item?.infillPercent,
fallback.infillDensity,
2,
),
infillPattern: this.normalizeString(
item?.infillPattern || fallback.infillPattern,
),
supportEnabled: Boolean(
item?.supportsEnabled ?? fallback.supportEnabled,
),
};
}
private buildBaselineMapFromRequest(
req: QuoteRequest,
): Map<string, TrackedPrintSettings> {
const map = new Map<string, TrackedPrintSettings>();
req.items.forEach((item) => {
map.set(
this.normalizeFileName(item.file?.name || ''),
this.toTrackedSettingsFromItem(req, item),
);
});
return map;
}
private buildBaselineMapFromSession(
items: any[],
defaultSettings: TrackedPrintSettings | null,
): Map<string, TrackedPrintSettings> {
const map = new Map<string, TrackedPrintSettings>();
const fallback = defaultSettings ?? this.defaultTrackedSettings();
items.forEach((item) => {
map.set(
this.normalizeFileName(item?.originalFilename || ''),
this.toTrackedSettingsFromSessionItem(item, fallback),
);
});
return map;
}
private defaultTrackedSettings(): TrackedPrintSettings {
return {
mode: this.mode(),
material: 'pla',
quality: 'standard',
nozzleDiameter: 0.4,
layerHeight: 0.2,
infillDensity: 20,
infillPattern: 'grid',
supportEnabled: false,
};
}
private refreshRecalculationRequirement(): void {
if (!this.result()) return;
const draft = this.uploadForm?.getCurrentRequestDraft();
if (!draft || draft.items.length === 0) {
this.requiresRecalculation.set(false);
return;
}
const fallback = this.baselinePrintSettings;
if (!fallback) {
this.requiresRecalculation.set(false);
return;
}
const changed = draft.items.some((item) => {
const key = this.normalizeFileName(item.file?.name || '');
const baseline = this.baselineItemSettingsByFileName.get(key) || fallback;
const current = this.toTrackedSettingsFromItem(draft, item);
return !this.sameTrackedSettings(baseline, current);
});
this.requiresRecalculation.set(changed);
}
private sameTrackedSettings(
a: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
},
b: {
mode: 'easy' | 'advanced';
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
},
a: TrackedPrintSettings,
b: TrackedPrintSettings,
): boolean {
return (
a.mode === b.mode &&
@@ -583,6 +708,10 @@ export class CalculatorPageComponent implements OnInit {
);
}
private normalizeFileName(fileName: string): string {
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
private normalizeString(value: string): string {
return String(value || '')
.trim()

View File

@@ -63,6 +63,12 @@
<span class="file-details">
{{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g
@if (getItemDifferenceLabel(item.fileName)) {
|
<small class="item-settings-diff">
{{ getItemDifferenceLabel(item.fileName) }}
</small>
}
</span>
</div>

View File

@@ -41,6 +41,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
.item-settings-diff {
margin-left: 2px;
font-size: 0.78rem;
font-weight: 600;
color: #8a6d1f;
white-space: normal;
}
.file-details {
font-size: 0.8rem;
color: var(--color-text-muted);

View File

@@ -36,6 +36,9 @@ export class QuoteResultComponent implements OnDestroy {
result = input.required<QuoteResult>();
recalculationRequired = input<boolean>(false);
itemSettingsDiffByFileName = input<Record<string, { differences: string[] }>>(
{},
);
consult = output<void>();
proceed = output<void>();
itemChange = output<{
@@ -185,4 +188,15 @@ export class QuoteResultComponent implements OnDestroy {
this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear();
}
getItemDifferenceLabel(fileName: string): string {
const differences =
this.itemSettingsDiffByFileName()[fileName]?.differences || [];
if (differences.length === 0) return '';
const materialOnly = differences.find(
(entry) => !entry.includes(':') && entry.trim().length > 0,
);
return materialOnly || differences.join(' | ');
}
}

View File

@@ -63,7 +63,7 @@
<app-color-selector
[selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()"
[variants]="getVariantsForItem(item)"
(colorSelected)="updateItemColor(i, $event)"
>
</app-color-selector>
@@ -145,6 +145,15 @@
}
</div>
@if (items().length > 1) {
<div class="checkbox-row sync-all-row">
<input type="checkbox" formControlName="syncAllItems" id="syncAllItems" />
<label for="syncAllItems">
Uguale per tutti i pezzi
</label>
</div>
}
<!-- Global quantity removed, now per item -->
@if (mode() === "advanced") {

View File

@@ -211,6 +211,12 @@
}
}
.sync-all-row {
margin-top: var(--space-2);
margin-bottom: var(--space-4);
padding-top: 0;
}
/* Progress Bar */
.progress-container {
margin-bottom: var(--space-3);

View File

@@ -37,8 +37,25 @@ interface FormItem {
quantity: number;
color: string;
filamentVariantId?: number;
printSettings: ItemPrintSettings;
}
interface ItemPrintSettings {
material: string;
quality: string;
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
supportEnabled: boolean;
}
interface ItemSettingsDiffInfo {
differences: string[];
}
type ItemPrintSettingsUpdate = Partial<ItemPrintSettings>;
@Component({
selector: 'app-upload-form',
standalone: true,
@@ -67,6 +84,7 @@ export class UploadFormComponent implements OnInit {
fileName: string;
quantity: number;
}>();
itemSettingsDiffChange = output<Record<string, ItemSettingsDiffInfo>>();
printSettingsChange = output<{
mode: 'easy' | 'advanced';
material: string;
@@ -108,7 +126,7 @@ export class UploadFormComponent implements OnInit {
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find((m) => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
this.syncSelectedItemVariantSelection();
} else {
this.currentMaterialVariants.set([]);
}
@@ -137,6 +155,7 @@ export class UploadFormComponent implements OnInit {
constructor() {
this.form = this.fb.group({
itemsTouched: [false], // Hack to track touched state for custom items list
syncAllItems: [true],
material: ['', Validators.required],
quality: ['', Validators.required],
items: [[]], // Track items in form for validation if needed
@@ -164,7 +183,9 @@ export class UploadFormComponent implements OnInit {
});
this.form.valueChanges.subscribe(() => {
if (this.isPatchingSettings) return;
this.syncSelectedItemSettingsFromForm();
this.emitPrintSettingsChange();
this.emitItemSettingsDiffChange();
});
effect(() => {
@@ -337,6 +358,7 @@ export class UploadFormComponent implements OnInit {
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
printSettings: this.getCurrentItemPrintSettings(),
});
}
}
@@ -349,7 +371,8 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
this.selectFile(validItems[validItems.length - 1].file);
this.emitItemSettingsDiffChange();
}
}
@@ -396,6 +419,7 @@ export class UploadFormComponent implements OnInit {
} else {
this.selectedFile.set(file);
}
this.loadSelectedItemSettingsIntoForm();
}
// Helper to get color of currently selected file
@@ -451,17 +475,55 @@ export class UploadFormComponent implements OnInit {
};
return updated;
});
this.emitItemSettingsDiffChange();
}
setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) {
if (!Number.isInteger(index) || index < 0) return;
let selectedItemUpdated = false;
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
const target = updated[index];
if (!target) return current;
const merged: ItemPrintSettings = {
...target.printSettings,
...update,
};
updated[index] = {
...target,
printSettings: merged,
};
selectedItemUpdated = target.file === this.selectedFile();
return updated;
});
if (selectedItemUpdated) {
this.loadSelectedItemSettingsIntoForm();
this.emitPrintSettingsChange();
}
this.emitItemSettingsDiffChange();
}
removeItem(index: number) {
let nextSelected: File | null = null;
this.items.update((current) => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null;
}
return updated;
});
if (nextSelected) {
this.selectFile(nextSelected);
} else if (this.items().length === 0) {
this.selectedFile.set(null);
}
this.emitItemSettingsDiffChange();
}
setFiles(files: File[]) {
@@ -474,6 +536,7 @@ export class UploadFormComponent implements OnInit {
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
printSettings: this.getCurrentItemPrintSettings(),
});
}
@@ -481,7 +544,8 @@ export class UploadFormComponent implements OnInit {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
this.selectFile(validItems[validItems.length - 1].file);
this.emitItemSettingsDiffChange();
}
}
@@ -510,25 +574,48 @@ export class UploadFormComponent implements OnInit {
return { colorName: 'Black' };
}
private syncItemVariantSelections(): void {
getVariantsForItem(item: FormItem): VariantOption[] {
return this.getVariantsForMaterialCode(item.printSettings.material);
}
private getVariantsForMaterialCode(materialCodeRaw: string): VariantOption[] {
const materialCode = String(materialCodeRaw || '').toUpperCase();
if (!materialCode) {
return [];
}
const material = this.fullMaterialOptions.find(
(option) => String(option.code || '').toUpperCase() === materialCode,
);
return material?.variants || [];
}
private syncSelectedItemVariantSelection(): void {
const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) {
return;
}
const selected = this.selectedFile();
if (!selected) {
return;
}
const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
this.items.update((current) =>
current.map((item) => {
if (item.file !== selected) {
return item;
}
const byId =
item.filamentVariantId != null
? vars.find((v) => v.id === item.filamentVariantId)
: null;
const byColor = vars.find((v) => v.colorName === item.color);
const selected = byId || byColor || fallback;
const selectedVariant = byId || byColor || fallback;
return {
...item,
color: selected.colorName,
filamentVariantId: selected.id,
color: selectedVariant.colorName,
filamentVariantId: selectedVariant.id,
};
}),
);
@@ -592,7 +679,7 @@ export class UploadFormComponent implements OnInit {
);
this.submitRequest.emit({
...this.form.getRawValue(),
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
items: this.toQuoteRequestItems(), // Include per-item print settings overrides
mode: this.mode(),
});
} else {
@@ -666,7 +753,7 @@ export class UploadFormComponent implements OnInit {
if (this.items().length === 0) return null;
const raw = this.form.getRawValue();
return {
items: this.items(),
items: this.toQuoteRequestItems(),
material: raw.material,
quality: raw.quality,
notes: raw.notes,
@@ -706,8 +793,248 @@ export class UploadFormComponent implements OnInit {
this.printSettingsChange.emit(this.getCurrentPrintSettings());
}
private loadSelectedItemSettingsIntoForm(): void {
const selected = this.selectedFile();
if (!selected) return;
const item = this.items().find((current) => current.file === selected);
if (!item) return;
this.isPatchingSettings = true;
this.form.patchValue(
{
material: item.printSettings.material,
quality: item.printSettings.quality,
nozzleDiameter: item.printSettings.nozzleDiameter,
layerHeight: item.printSettings.layerHeight,
infillDensity: item.printSettings.infillDensity,
infillPattern: item.printSettings.infillPattern,
supportEnabled: item.printSettings.supportEnabled,
},
{ emitEvent: false },
);
this.isPatchingSettings = false;
this.updateLayerHeightOptionsForNozzle(
item.printSettings.nozzleDiameter,
true,
);
this.updateVariants();
}
private syncSelectedItemSettingsFromForm(): void {
const currentSettings = this.getCurrentItemPrintSettings();
if (this.shouldApplySettingsToAllItems()) {
this.applyCurrentSettingsToAllItems(currentSettings);
return;
}
const selected = this.selectedFile();
if (!selected) return;
this.items.update((current) =>
current.map((item) => {
if (item.file !== selected) {
return item;
}
const variants = this.getVariantsForMaterialCode(currentSettings.material);
const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
const byId =
item.filamentVariantId != null
? variants.find((v) => v.id === item.filamentVariantId)
: null;
const byColor = variants.find((v) => v.colorName === item.color);
const selectedVariant = byId || byColor || fallback;
return {
...item,
printSettings: { ...currentSettings },
color: selectedVariant ? selectedVariant.colorName : item.color,
filamentVariantId: selectedVariant ? selectedVariant.id : undefined,
};
}),
);
}
private emitItemSettingsDiffChange(): void {
const currentItems = this.items();
if (currentItems.length === 0) {
this.itemSettingsDiffChange.emit({});
return;
}
const signatureCounts = new Map<string, number>();
currentItems.forEach((item) => {
const signature = this.settingsSignature(item.printSettings);
signatureCounts.set(signature, (signatureCounts.get(signature) || 0) + 1);
});
let dominantSignature = '';
let dominantCount = 0;
signatureCounts.forEach((count, signature) => {
if (count > dominantCount) {
dominantCount = count;
dominantSignature = signature;
}
});
const hasDominant = dominantCount > 1;
const dominantSettings = hasDominant
? currentItems.find(
(item) =>
this.settingsSignature(item.printSettings) === dominantSignature,
)?.printSettings
: null;
const diffByFileName: Record<string, ItemSettingsDiffInfo> = {};
currentItems.forEach((item) => {
const differences = dominantSettings
? this.describeSettingsDifferences(dominantSettings, item.printSettings)
: [];
diffByFileName[item.file.name] = {
differences,
};
});
this.itemSettingsDiffChange.emit(diffByFileName);
}
private sameItemPrintSettings(
a: ItemPrintSettings,
b: ItemPrintSettings,
): boolean {
return (
a.material.trim().toUpperCase() === b.material.trim().toUpperCase() &&
a.quality.trim().toLowerCase() === b.quality.trim().toLowerCase() &&
Math.abs(a.nozzleDiameter - b.nozzleDiameter) < 0.0001 &&
Math.abs(a.layerHeight - b.layerHeight) < 0.0001 &&
Math.abs(a.infillDensity - b.infillDensity) < 0.0001 &&
a.infillPattern.trim().toLowerCase() ===
b.infillPattern.trim().toLowerCase() &&
Boolean(a.supportEnabled) === Boolean(b.supportEnabled)
);
}
private settingsSignature(settings: ItemPrintSettings): string {
return JSON.stringify({
material: settings.material.trim().toUpperCase(),
quality: settings.quality.trim().toLowerCase(),
nozzleDiameter: Number(settings.nozzleDiameter.toFixed(2)),
layerHeight: Number(settings.layerHeight.toFixed(3)),
infillDensity: Number(settings.infillDensity.toFixed(2)),
infillPattern: settings.infillPattern.trim().toLowerCase(),
supportEnabled: Boolean(settings.supportEnabled),
});
}
private describeSettingsDifferences(
baseline: ItemPrintSettings,
current: ItemPrintSettings,
): string[] {
if (this.sameItemPrintSettings(baseline, current)) {
return [];
}
const differences: string[] = [];
if (baseline.material.trim().toUpperCase() !== current.material.trim().toUpperCase()) {
differences.push(`${current.material}`);
}
if (baseline.quality.trim().toLowerCase() !== current.quality.trim().toLowerCase()) {
differences.push(`Qualita: ${current.quality}`);
}
if (Math.abs(baseline.nozzleDiameter - current.nozzleDiameter) >= 0.0001) {
differences.push(`Nozzle: ${current.nozzleDiameter.toFixed(1)} mm`);
}
if (Math.abs(baseline.layerHeight - current.layerHeight) >= 0.0001) {
differences.push(`Layer: ${current.layerHeight.toFixed(2)} mm`);
}
if (Math.abs(baseline.infillDensity - current.infillDensity) >= 0.0001) {
differences.push(`Infill: ${current.infillDensity}%`);
}
if (
baseline.infillPattern.trim().toLowerCase() !==
current.infillPattern.trim().toLowerCase()
) {
differences.push(`Pattern: ${current.infillPattern}`);
}
if (Boolean(baseline.supportEnabled) !== Boolean(current.supportEnabled)) {
differences.push(
`Supporti: ${current.supportEnabled ? 'attivi' : 'disattivi'}`,
);
}
return differences;
}
private toQuoteRequestItems(): QuoteRequest['items'] {
return this.items().map((item) => ({
file: item.file,
quantity: item.quantity,
color: item.color,
filamentVariantId: item.filamentVariantId,
material: item.printSettings.material,
quality: item.printSettings.quality,
nozzleDiameter: item.printSettings.nozzleDiameter,
layerHeight: item.printSettings.layerHeight,
infillDensity: item.printSettings.infillDensity,
infillPattern: item.printSettings.infillPattern,
supportEnabled: item.printSettings.supportEnabled,
}));
}
private getCurrentItemPrintSettings(): ItemPrintSettings {
const settings = this.getCurrentPrintSettings();
return {
material: settings.material,
quality: settings.quality,
nozzleDiameter: settings.nozzleDiameter,
layerHeight: settings.layerHeight,
infillDensity: settings.infillDensity,
infillPattern: settings.infillPattern,
supportEnabled: settings.supportEnabled,
};
}
private shouldApplySettingsToAllItems(): boolean {
return this.parseBooleanControlValue(this.form.get('syncAllItems')?.value);
}
private applyCurrentSettingsToAllItems(currentSettings: ItemPrintSettings): void {
this.items.update((current) =>
current.map((item) => {
const variants = this.getVariantsForMaterialCode(currentSettings.material);
const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
const byId =
item.filamentVariantId != null
? variants.find((v) => v.id === item.filamentVariantId)
: null;
const byColor = variants.find((v) => v.colorName === item.color);
const selectedVariant = byId || byColor || fallback;
return {
...item,
printSettings: { ...currentSettings },
color: selectedVariant ? selectedVariant.colorName : item.color,
filamentVariantId: selectedVariant ? selectedVariant.id : undefined,
};
}),
);
}
private parseBooleanControlValue(raw: unknown): boolean {
if (this.items().length <= 1) {
return false;
}
if (raw === true || raw === 1) {
return true;
}
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'on';
}
return false;
}
private applySettingsLock(locked: boolean): void {
const controlsToLock = [
'syncAllItems',
'material',
'quality',
'nozzleDiameter',

View File

@@ -10,6 +10,13 @@ export interface QuoteRequest {
quantity: number;
color?: string;
filamentVariantId?: number;
material?: string;
quality?: string;
nozzleDiameter?: number;
layerHeight?: number;
infillDensity?: number;
infillPattern?: string;
supportEnabled?: boolean;
}[];
material: string;
quality: string;
@@ -150,7 +157,7 @@ export class QuoteEstimatorService {
if (normalized === 'draft') {
return {
quality: 'extra_fine',
quality: 'draft',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
@@ -306,23 +313,28 @@ export class QuoteEstimatorService {
request.mode === 'easy'
? 'ADVANCED'
: request.mode.toUpperCase(),
material: request.material,
material: item.material || request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
quality: easyPreset
? easyPreset.quality
: item.quality || request.quality,
supportsEnabled:
easyPreset != null
? request.supportEnabled
: item.supportEnabled ?? request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset
? easyPreset.layerHeight
: request.layerHeight,
: item.layerHeight ?? request.layerHeight,
infillDensity: easyPreset
? easyPreset.infillDensity
: request.infillDensity,
: item.infillDensity ?? request.infillDensity,
infillPattern: easyPreset
? easyPreset.infillPattern
: request.infillPattern,
: item.infillPattern ?? request.infillPattern,
nozzleDiameter: easyPreset
? easyPreset.nozzleDiameter
: request.nozzleDiameter,
: item.nozzleDiameter ?? request.nozzleDiameter,
};
const settingsBlob = new Blob([JSON.stringify(settings)], {
@@ -477,9 +489,7 @@ export class QuoteEstimatorService {
unitTime: item.printTimeSeconds,
unitWeight: item.materialGrams,
quantity: item.quantity,
material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode.
// But line items might have different colors.
material: item.materialCode || session.materialCode,
color: item.colorCode,
filamentVariantId: item.filamentVariantId,
})),

View File

@@ -245,6 +245,10 @@
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span>
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ itemMaterial(item) }}
</span>
<span
*ngIf="item.colorCode"
class="color-dot"
@@ -255,7 +259,7 @@
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
</div>
<div class="item-preview" *ngIf="isStlItem(item)">
<div class="item-preview" *ngIf="isCadSession() && isStlItem(item)">
<ng-container
*ngIf="previewFile(item) as itemPreview; else previewState"
>

View File

@@ -162,7 +162,11 @@ export class CheckoutComponent implements OnInit {
this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => {
this.quoteSession.set(session);
this.loadStlPreviews(session);
if (this.isCadSessionData(session)) {
this.loadStlPreviews(session);
} else {
this.resetPreviewState();
}
console.log('Loaded session:', session);
},
error: (err) => {
@@ -173,7 +177,7 @@ export class CheckoutComponent implements OnInit {
}
isCadSession(): boolean {
return this.quoteSession()?.session?.status === 'CAD_ACTIVE';
return this.isCadSessionData(this.quoteSession());
}
cadRequestId(): string | null {
@@ -188,6 +192,12 @@ export class CheckoutComponent implements OnInit {
return this.quoteSession()?.cadTotalChf ?? 0;
}
itemMaterial(item: any): string {
return String(
item?.materialCode ?? this.quoteSession()?.session?.materialCode ?? '-',
);
}
isStlItem(item: any): boolean {
const name = String(item?.originalFilename ?? '').toLowerCase();
return name.endsWith('.stl');
@@ -241,7 +251,11 @@ export class CheckoutComponent implements OnInit {
}
private loadStlPreviews(session: any): void {
if (!this.sessionId || !Array.isArray(session?.items)) {
if (
!this.sessionId ||
!this.isCadSessionData(session) ||
!Array.isArray(session?.items)
) {
return;
}
@@ -276,6 +290,17 @@ export class CheckoutComponent implements OnInit {
}
}
private isCadSessionData(session: any): boolean {
return session?.session?.status === 'CAD_ACTIVE';
}
private resetPreviewState(): void {
this.previewFiles.set({});
this.previewLoading.set({});
this.previewErrors.set({});
this.closePreview();
}
onSubmit() {
if (this.checkoutForm.invalid) {
return;

View File

@@ -402,6 +402,7 @@
"SETUP_FEE": "Einrichtungskosten",
"TOTAL": "Gesamt",
"QTY": "Menge",
"MATERIAL": "Material",
"PER_PIECE": "pro Stück",
"SHIPPING": "Versand (CH)",
"PREVIEW_LOADING": "3D-Vorschau wird geladen...",

View File

@@ -402,6 +402,7 @@
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"QTY": "Qty",
"MATERIAL": "Material",
"PER_PIECE": "per piece",
"SHIPPING": "Shipping",
"PREVIEW_LOADING": "Loading 3D preview...",

View File

@@ -459,6 +459,7 @@
"SETUP_FEE": "Coût de setup",
"TOTAL": "Total",
"QTY": "Qté",
"MATERIAL": "Matériau",
"PER_PIECE": "par pièce",
"SHIPPING": "Expédition (CH)",
"PREVIEW_LOADING": "Chargement de l'aperçu 3D...",

View File

@@ -459,6 +459,7 @@
"SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale",
"QTY": "Qtà",
"MATERIAL": "Materiale",
"PER_PIECE": "al pezzo",
"SHIPPING": "Spedizione (CH)",
"PREVIEW_LOADING": "Caricamento anteprima 3D...",