style: apply prettier formatting

This commit is contained in:
printcalc-ci
2026-03-03 11:46:26 +00:00
parent dd6f723271
commit 20293cc044
131 changed files with 5674 additions and 3482 deletions

View File

@@ -1,72 +1,80 @@
<div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
<h1>{{ "CALC.TITLE" | translate }}</h1>
<p class="subtitle">{{ "CALC.SUBTITLE" | translate }}</p>
@if (error()) {
<app-alert type="error">{{ errorKey() | translate }}</app-alert>
}
</div>
@if (step() === 'success') {
<div class="container hero">
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
</div>
@if (step() === "success") {
<div class="container hero">
<app-success-state
context="calc"
(action)="onNewQuote()"
></app-success-state>
</div>
} @else {
<div class="container content-grid">
<!-- Left Column: Input -->
<div class="col-input">
<app-card>
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
<div class="container content-grid">
<!-- Left Column: Input -->
<div class="col-input">
<app-card>
<div class="mode-selector">
<div
class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')"
>
{{ "CALC.MODE_EASY" | translate }}
</div>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
</div>
</app-card>
} @else if (result()) {
<app-quote-result
[result]="result()!"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemChange)="onItemChange($event)"
></app-quote-result>
} @else {
<app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
<ul class="benefits">
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
</ul>
</app-card>
}
</div>
<div
class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"
>
{{ "CALC.MODE_ADVANCED" | translate }}
</div>
</div>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }}
</h3>
<p class="loading-text">{{ "CALC.ANALYZING_TEXT" | translate }}</p>
</div>
</app-card>
} @else if (result()) {
<app-quote-result
[result]="result()!"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemChange)="onItemChange($event)"
></app-quote-result>
} @else {
<app-card>
<h3>{{ "CALC.BENEFITS_TITLE" | translate }}</h3>
<ul class="benefits">
<li>{{ "CALC.BENEFITS_1" | translate }}</li>
<li>{{ "CALC.BENEFITS_2" | translate }}</li>
<li>{{ "CALC.BENEFITS_3" | translate }}</li>
</ul>
</app-card>
}
</div>
</div>
}

View File

@@ -1,36 +1,44 @@
.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; }
.hero {
padding: var(--space-12) 0;
text-align: center;
}
.subtitle {
font-size: 1.25rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
@media(min-width: 768px) {
@media (min-width: 768px) {
grid-template-columns: 1.5fr 1fr;
gap: var(--space-8);
}
}
.centered-col {
align-self: flex-start; /* Default */
@media(min-width: 768px) {
align-self: center;
}
align-self: flex-start; /* Default */
@media (min-width: 768px) {
align-self: center;
}
}
.col-input {
min-width: 0;
min-width: 0;
}
.col-result {
min-width: 0;
display: flex;
flex-direction: column;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Stretch only the loading card so the spinner stays centered */
.col-result > .loading-state {
flex: 1;
flex: 1;
}
/* Mode Selector (Segmented Control style) */
@@ -56,55 +64,64 @@
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&:hover {
color: var(--color-text);
}
&.active {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
.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;
}
.loader-content {
text-align: center;
max-width: 300px;
margin: 0 auto;
/* Center content vertically within the stretched card */
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
max-width: 300px;
margin: 0 auto;
/* Center content vertically within the stretched card */
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.loading-title {
font-size: 1.1rem;
font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
color: var(--color-text);
font-size: 1.1rem;
font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
color: var(--color-text);
}
.loading-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.spinner {
border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto;
border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,4 +1,10 @@
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import {
Component,
signal,
ViewChild,
ElementRef,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { forkJoin } from 'rxjs';
@@ -8,7 +14,11 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
import {
QuoteRequest,
QuoteResult,
QuoteEstimatorService,
} from './services/quote-estimator.service';
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service';
@@ -16,142 +26,155 @@ import { LanguageService } from '../../core/services/language.service';
@Component({
selector: 'app-calculator-page',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent],
imports: [
CommonModule,
TranslateModule,
AppCardComponent,
AppAlertComponent,
UploadFormComponent,
QuoteResultComponent,
SuccessStateComponent,
],
templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss'
styleUrl: './calculator-page.component.scss',
})
export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
error = signal<boolean>(false);
errorKey = signal<string>('CALC.ERROR_GENERIC');
orderSuccess = signal(false);
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
constructor(
private estimator: QuoteEstimatorService,
private estimator: QuoteEstimatorService,
private router: Router,
private route: ActivatedRoute,
private languageService: LanguageService
private languageService: LanguageService,
) {}
ngOnInit() {
this.route.data.subscribe(data => {
this.route.data.subscribe((data) => {
if (data['mode']) {
this.mode.set(data['mode']);
}
});
this.route.queryParams.subscribe(params => {
const sessionId = params['session'];
if (sessionId) {
// Avoid reloading if we just calculated this session
const currentRes = this.result();
if (!currentRes || currentRes.sessionId !== sessionId) {
this.loadSession(sessionId);
}
this.route.queryParams.subscribe((params) => {
const sessionId = params['session'];
if (sessionId) {
// Avoid reloading if we just calculated this session
const currentRes = this.result();
if (!currentRes || currentRes.sessionId !== sessionId) {
this.loadSession(sessionId);
}
}
});
}
loadSession(sessionId: string) {
this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({
next: (data) => {
// 1. Map to Result
const result = this.estimator.mapSessionToQuoteResult(data);
if (this.isInvalidQuote(result)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({
next: (data) => {
// 1. Map to Result
const result = this.estimator.mapSessionToQuoteResult(data);
if (this.isInvalidQuote(result)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
// If we have custom settings, maybe Advanced?
// For now, let's stick to current mode or infer from URL if possible.
// Actually, we can check if settings deviate from Easy defaults.
// But let's leave it as is or default to Advanced if not sure.
// data.session.materialCode etc.
// 3. Download Files & Restore Form
this.restoreFilesAndSettings(data.session, data.items);
},
error: (err) => {
console.error('Failed to load session', err);
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
}
});
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
// If we have custom settings, maybe Advanced?
// For now, let's stick to current mode or infer from URL if possible.
// Actually, we can check if settings deviate from Easy defaults.
// But let's leave it as is or default to Advanced if not sure.
// data.session.materialCode etc.
// 3. Download Files & Restore Form
this.restoreFilesAndSettings(data.session, data.items);
},
error: (err) => {
console.error('Failed to load session', err);
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
},
});
}
restoreFilesAndSettings(session: any, items: any[]) {
if (!items || items.length === 0) {
this.loading.set(false);
return;
}
if (!items || items.length === 0) {
this.loading.set(false);
return;
}
// Download all files
const downloads = items.map(item =>
this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => {
return {
blob,
fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally.
// UploadForm.setFiles takes File[].
// We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list.
};
})
)
);
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
if (this.uploadForm) {
this.uploadForm.setFiles(files);
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
});
}
});
}
// Download all files
const downloads = items.map((item) =>
this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => {
return {
blob,
fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally.
// UploadForm.setFiles takes File[].
// We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list.
};
}),
),
);
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(
(res) =>
new File([res.blob], res.fileName, {
type: 'application/octet-stream',
}),
);
if (this.uploadForm) {
this.uploadForm.setFiles(files);
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,
});
}
this.loading.set(false);
},
error: (err: any) => {
console.error('Failed to download files', err);
this.loading.set(false);
// Still show result? Yes.
}
});
}
});
}
});
}
this.loading.set(false);
},
error: (err: any) => {
console.error('Failed to download files', err);
this.loading.set(false);
// Still show result? Yes.
},
});
}
onCalculate(req: QuoteRequest) {
@@ -166,46 +189,49 @@ export class CalculatorPageComponent implements OnInit {
// Auto-scroll on mobile to make analysis visible
setTimeout(() => {
if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);
this.estimator.calculate(req).subscribe({
next: (event) => {
if (typeof event === 'number') {
this.uploadProgress.set(event);
this.uploadProgress.set(event);
} else {
// It's the result
const res = event as QuoteResult;
if (this.isInvalidQuote(res)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
// It's the result
const res = event as QuoteResult;
if (this.isInvalidQuote(res)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
return;
}
// Update URL with session ID without reloading
if (res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
});
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
// Update URL with session ID without reloading
if (res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
});
}
}
},
error: () => {
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
}
},
});
}
@@ -214,7 +240,7 @@ export class CalculatorPageComponent implements OnInit {
if (res && res.sessionId) {
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{ queryParams: { session: res.sessionId } }
{ queryParams: { session: res.sessionId } },
);
} else {
console.error('No session ID found in quote result');
@@ -226,59 +252,67 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote');
}
onItemChange(event: {id?: string, index: number, fileName: string, quantity: number}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
// 2. Update backend session if ID exists
if (event.id) {
const currentSessionId = this.result()?.sessionId;
if (!currentSessionId) return;
onItemChange(event: {
id?: string;
index: number;
fileName: string;
quantity: number;
}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
next: () => {
// 3. Fetch the updated session totals from the backend
this.estimator.getQuoteSession(currentSessionId).subscribe({
next: (sessionData) => {
const newResult = this.estimator.mapSessionToQuoteResult(sessionData);
// Preserve notes
newResult.notes = this.result()?.notes;
// 2. Update backend session if ID exists
if (event.id) {
const currentSessionId = this.result()?.sessionId;
if (!currentSessionId) return;
if (this.isInvalidQuote(newResult)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
return;
}
this.estimator
.updateLineItem(event.id, { quantity: event.quantity })
.subscribe({
next: () => {
// 3. Fetch the updated session totals from the backend
this.estimator.getQuoteSession(currentSessionId).subscribe({
next: (sessionData) => {
const newResult =
this.estimator.mapSessionToQuoteResult(sessionData);
// Preserve notes
newResult.notes = this.result()?.notes;
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(newResult);
},
error: (err) => {
console.error('Failed to refresh session totals', err);
}
});
if (this.isInvalidQuote(newResult)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(newResult);
},
error: (err) => {
console.error('Failed to update line item', err);
}
});
}
console.error('Failed to refresh session totals', err);
},
});
},
error: (err) => {
console.error('Failed to update line item', err);
},
});
}
}
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
this.step.set('success');
this.step.set('success');
}
onNewQuote() {
this.step.set('upload');
this.result.set(null);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
this.step.set('upload');
this.result.set(null);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
}
private currentRequest: QuoteRequest | null = null;
@@ -290,25 +324,25 @@ export class CalculatorPageComponent implements OnInit {
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
details += `- File:\n`;
req.items.forEach(item => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
req.items.forEach((item) => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
});
if (req.mode === 'advanced') {
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
}
if (req.notes) details += `\nNote: ${req.notes}`;
this.estimator.setPendingConsultation({
files: req.items.map(i => i.file),
message: details
files: req.items.map((i) => i.file),
message: details,
});
this.router.navigate(['/', this.languageService.selectedLang(), 'contact']);

View File

@@ -4,5 +4,9 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
{
path: 'advanced',
component: CalculatorPageComponent,
data: { mode: 'advanced' },
},
];

View File

@@ -1,14 +1,15 @@
<app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<h3 class="title">{{ "CALC.RESULT" | translate }}</h3>
<!-- Summary Grid (NOW ON TOP) -->
<div class="result-grid">
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true">
{{ totals().price | currency:result().currency }}
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true"
>
{{ totals().price | currency: result().currency }}
</app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate">
@@ -21,19 +22,26 @@
</div>
<div class="setup-note">
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small><br>
<small class="shipping-note" style="color: #666;">{{ 'CALC.SHIPPING_NOTE' | translate }}</small>
<small>{{
"CALC.SETUP_NOTE"
| translate
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
<small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate
}}</small>
</div>
@if (result().notes) {
<div class="notes-section">
<label>{{ 'CALC.NOTES' | translate }}:</label>
<p>{{ result().notes }}</p>
<label>{{ "CALC.NOTES" | translate }}:</label>
<p>{{ result().notes }}</p>
</div>
}
<div class="divider"></div>
<!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list">
@for (item of items(); track item.fileName; let i = $index) {
@@ -41,33 +49,41 @@
<div class="item-info">
<span class="file-name">{{ item.fileName }}</span>
<span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
{{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g
</span>
</div>
<div class="item-controls">
<div class="qty-control">
<label>{{ 'CHECKOUT.QTY' | translate }}:</label>
<input
type="number"
min="1"
[max]="maxInputQuantity"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)"
class="qty-input">
</div>
<div class="item-price">
<span class="item-total-price">
{{ (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">&nbsp;</small>
</ng-template>
</div>
<div class="qty-control">
<label>{{ "CHECKOUT.QTY" | translate }}:</label>
<input
type="number"
min="1"
[max]="maxInputQuantity"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)"
class="qty-input"
/>
</div>
<div class="item-price">
<span class="item-total-price">
{{ 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"
>&nbsp;</small
>
</ng-template>
</div>
</div>
</div>
}
@@ -75,15 +91,17 @@
<div class="actions">
<app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }}
{{ "QUOTE.CONSULT" | translate }}
</app-button>
@if (!hasQuantityOverLimit()) {
<app-button (click)="proceed.emit()">
{{ 'QUOTE.PROCEED_ORDER' | translate }}
{{ "QUOTE.PROCEED_ORDER" | translate }}
</app-button>
} @else {
<small class="limit-note">{{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }}</small>
<small class="limit-note">{{
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}</small>
}
</div>
</app-card>

View File

@@ -1,86 +1,105 @@
.title { margin-bottom: var(--space-6); text-align: center; }
.title {
margin-bottom: var(--space-6);
text-align: center;
}
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-4) 0;
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-4) 0;
}
.items-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.item-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1; /* Ensure it takes available space */
display: flex;
flex-direction: column;
min-width: 0;
flex: 1; /* Ensure it takes available space */
}
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
.file-name {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.item-controls {
display: flex;
align-items: center;
gap: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
}
.qty-control {
display: flex;
align-items: center;
gap: var(--space-2);
label { font-size: 0.8rem; color: var(--color-text-muted); }
display: flex;
align-items: center;
gap: var(--space-2);
label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
}
.qty-input {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
&:focus { outline: none; border-color: var(--color-brand); }
width: 60px;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
&:focus {
outline: none;
border-color: var(--color-brand);
}
}
.item-price {
font-weight: 600;
min-width: 60px;
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
min-height: 2.1rem;
font-weight: 600;
min-width: 60px;
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;
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;
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;
visibility: hidden;
}
.result-grid {
@@ -88,50 +107,56 @@
grid-template-columns: 1fr;
gap: var(--space-3);
margin-bottom: var(--space-2);
@media(min-width: 500px) {
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
@media (min-width: 500px) {
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
}
.full-width { grid-column: span 2; }
.setup-note {
text-align: center;
margin-bottom: var(--space-6);
color: var(--color-text-muted);
font-size: 0.8rem;
.full-width {
grid-column: span 2;
}
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
.setup-note {
text-align: center;
margin-bottom: var(--space-6);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.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);
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
margin-top: calc(var(--space-2) * -1);
}
.notes-section {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
label {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-muted);
display: block;
margin-bottom: var(--space-2);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
white-space: pre-wrap; /* Preserve line breaks */
}
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
label {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-muted);
display: block;
margin-bottom: var(--space-2);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
white-space: pre-wrap; /* Preserve line breaks */
}
}

View File

@@ -1,4 +1,12 @@
import { Component, OnDestroy, input, output, signal, computed, effect } from '@angular/core';
import {
Component,
OnDestroy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -10,9 +18,16 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({
selector: 'app-quote-result',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
imports: [
CommonModule,
FormsModule,
TranslateModule,
AppCardComponent,
AppButtonComponent,
SummaryCardComponent,
],
templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss'
styleUrl: './quote-result.component.scss',
})
export class QuoteResultComponent implements OnDestroy {
readonly maxInputQuantity = 500;
@@ -22,7 +37,12 @@ export class QuoteResultComponent implements OnDestroy {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{id?: string, index: number, fileName: string, quantity: number}>();
itemChange = output<{
id?: string;
index: number;
fileName: string;
quantity: number;
}>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
@@ -30,120 +50,124 @@ export class QuoteResultComponent implements OnDestroy {
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor() {
effect(() => {
this.clearAllQuantityTimers();
effect(
() => {
this.clearAllQuantityTimers();
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map(i => ({...i}));
this.items.set(nextItems);
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map((i) => ({ ...i }));
this.items.set(nextItems);
this.lastSentQuantities.clear();
nextItems.forEach(item => {
const key = item.id ?? item.fileName;
this.lastSentQuantities.set(key, item.quantity);
});
}, { allowSignalWrites: true });
this.lastSentQuantities.clear();
nextItems.forEach((item) => {
const key = item.id ?? item.fileName;
this.lastSentQuantities.set(key, item.quantity);
});
},
{ allowSignalWrites: true },
);
}
ngOnDestroy(): void {
this.clearAllQuantityTimers();
this.clearAllQuantityTimers();
}
updateQuantity(index: number, newQty: number | string) {
const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return;
const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.items.update((current) => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.scheduleQuantityRefresh(index, key);
this.scheduleQuantityRefresh(index, key);
}
flushQuantityUpdate(index: number): void {
const item = this.items()[index];
if (!item) return;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
this.clearQuantityRefreshTimer(key);
const key = item.id ?? item.fileName;
this.clearQuantityRefreshTimer(key);
const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return;
const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return;
if (this.lastSentQuantities.get(key) === normalizedQty) {
return;
}
if (this.lastSentQuantities.get(key) === normalizedQty) {
return;
}
this.itemChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty
});
this.lastSentQuantities.set(key, normalizedQty);
this.itemChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty,
});
this.lastSentQuantities.set(key, normalizedQty);
}
hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit));
hasQuantityOverLimit = computed(() =>
this.items().some((item) => item.quantity > this.directOrderLimit),
);
totals = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach(i => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight)
};
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach((i) => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight),
};
});
private normalizeQuantity(newQty: number | string): number | null {
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (!Number.isFinite(qty) || qty < 1) {
return null;
}
return Math.min(qty, this.maxInputQuantity);
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (!Number.isFinite(qty) || qty < 1) {
return null;
}
return Math.min(qty, this.maxInputQuantity);
}
private scheduleQuantityRefresh(index: number, key: string): void {
this.clearQuantityRefreshTimer(key);
const timer = setTimeout(() => {
this.quantityTimers.delete(key);
this.flushQuantityUpdate(index);
}, this.quantityAutoRefreshMs);
this.quantityTimers.set(key, timer);
this.clearQuantityRefreshTimer(key);
const timer = setTimeout(() => {
this.quantityTimers.delete(key);
this.flushQuantityUpdate(index);
}, this.quantityAutoRefreshMs);
this.quantityTimers.set(key, timer);
}
private clearQuantityRefreshTimer(key: string): void {
const timer = this.quantityTimers.get(key);
if (!timer) return;
clearTimeout(timer);
this.quantityTimers.delete(key);
const timer = this.quantityTimers.get(key);
if (!timer) return;
clearTimeout(timer);
this.quantityTimers.delete(key);
}
private clearAllQuantityTimers(): void {
this.quantityTimers.forEach(timer => clearTimeout(timer));
this.quantityTimers.clear();
this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear();
}
}

View File

@@ -1,95 +1,119 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
@if (selectedFile()) {
<div class="viewer-wrapper">
@if (!isStepFile(selectedFile())) {
<div class="step-warning">
<p>{{ 'CALC.STEP_WARNING' | translate }}</p>
</div>
} @else {
<app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()">
</app-stl-viewer>
}
<!-- Close button removed as requested -->
@if (!isStepFile(selectedFile())) {
<div class="step-warning">
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
</div>
} @else {
<app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()"
>
</app-stl-viewer>
}
<!-- Close button removed as requested -->
</div>
}
<!-- Initial Dropzone (Visible only when no files) -->
@if (items().length === 0) {
<app-dropzone
[label]="'CALC.UPLOAD_LABEL'"
[subtext]="'CALC.UPLOAD_SUB'"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)">
</app-dropzone>
<app-dropzone
[label]="'CALC.UPLOAD_LABEL'"
[subtext]="'CALC.UPLOAD_SUB'"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)"
>
</app-dropzone>
}
<!-- New File List with Details -->
@if (items().length > 0) {
<div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
</div>
<div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) {
<div
class="file-card"
[class.active]="item.file === selectedFile()"
(click)="selectFile(item.file)"
>
<div class="card-header">
<span class="file-name" [title]="item.file.name">{{
item.file.name
}}</span>
</div>
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>{{ 'CALC.QTY_SHORT' | translate }}</label>
<input
type="number"
min="1"
[value]="item.quantity"
(change)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()">
</div>
<div class="color-group">
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
<app-color-selector
[selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
</div>
<button
type="button"
class="btn-remove"
(click)="removeItem(i); $event.stopPropagation()"
[attr.title]="'CALC.REMOVE_FILE' | translate">
X
</button>
</div>
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>{{ "CALC.QTY_SHORT" | translate }}</label>
<input
type="number"
min="1"
[value]="item.quantity"
(change)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()"
/>
</div>
}
</div>
<!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container">
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)">
<div class="color-group">
<label>{{ "CALC.COLOR_LABEL" | translate }}</label>
<app-color-selector
[selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)"
>
</app-color-selector>
</div>
</div>
<button type="button" class="btn-add-more" (click)="additionalInput.click()">
+ {{ 'CALC.ADD_FILES' | translate }}
</button>
</div>
<button
type="button"
class="btn-remove"
(click)="removeItem(i); $event.stopPropagation()"
[attr.title]="'CALC.REMOVE_FILE' | translate"
>
X
</button>
</div>
</div>
}
</div>
<!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container">
<input
#additionalInput
type="file"
[accept]="acceptedFormats"
multiple
hidden
(change)="onAdditionalFilesSelected($event)"
/>
<button
type="button"
class="btn-add-more"
(click)="additionalInput.click()"
>
+ {{ "CALC.ADD_FILES" | translate }}
</button>
</div>
}
@if (items().length === 0 && form.get('itemsTouched')?.value) {
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
@if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
}
<p class="upload-privacy-note">
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>.
</p>
</div>
@@ -100,51 +124,50 @@
[options]="materials()"
></app-select>
@if (mode() === 'easy') {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
@if (mode() === "easy") {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
}
</div>
<!-- Global quantity removed, now per item -->
@if (mode() === 'advanced') {
@if (mode() === "advanced") {
<div class="grid">
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
</div>
<div class="grid">
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support">
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
</div>
<div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support" />
<label for="support">{{ "CALC.SUPPORT" | translate }}</label>
</div>
</div>
}
<app-input
@@ -156,18 +179,25 @@
<div class="actions">
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
@if (loading() && uploadProgress() < 100) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
</div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
</div>
</div>
}
<app-button
type="submit"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
[fullWidth]="true"
>
{{
loading()
? uploadProgress() < 100
? ("CALC.UPLOADING" | translate)
: ("CALC.PROCESSING" | translate)
: ("CALC.CALCULATE" | translate)
}}
</app-button>
</div>
</form>

View File

@@ -1,226 +1,246 @@
.section { margin-bottom: var(--space-6); }
.section {
margin-bottom: var(--space-6);
}
.upload-privacy-note {
margin-top: var(--space-3);
margin-bottom: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
text-align: left;
margin-top: var(--space-3);
margin-bottom: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
text-align: left;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
@media(min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.actions { margin-top: var(--space-6); }
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.actions {
margin-top: var(--space-6);
}
.error-msg {
color: var(--color-danger-500);
font-size: 0.875rem;
margin-top: var(--space-2);
text-align: center;
}
.viewer-wrapper {
position: relative;
margin-bottom: var(--space-4);
}
/* Grid Layout for Files */
.items-grid {
display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 640px) {
gap: var(--space-3);
}
display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media (min-width: 640px) {
gap: var(--space-3);
}
}
.file-card {
padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover { border-color: var(--color-neutral-300); }
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05);
box-shadow: 0 0 0 1px var(--color-brand);
}
padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover {
border-color: var(--color-neutral-300);
}
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05);
box-shadow: 0 0 0 1px var(--color-brand);
}
}
.card-header {
overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
}
.file-name {
font-weight: 500;
font-size: 0.8rem; /* Smaller font */
color: var(--color-text);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.file-name {
font-weight: 500;
font-size: 0.8rem; /* Smaller font */
color: var(--color-text);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-body {
display: flex;
align-items: center;
padding-top: 0;
display: flex;
align-items: center;
padding-top: 0;
}
.card-controls {
display: flex;
align-items: flex-end; /* Align bottom of input and color circle */
gap: 16px; /* Space between Qty and Color */
width: 100%;
display: flex;
align-items: flex-end; /* Align bottom of input and color circle */
gap: 16px; /* Space between Qty and Color */
width: 100%;
}
.qty-group, .color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
.qty-group,
.color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
}
.color-group {
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
}
.qty-input {
width: 36px; /* Slightly smaller */
padding: 1px 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: 0.85rem;
background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus { outline: none; border-color: var(--color-brand); }
width: 36px; /* Slightly smaller */
padding: 1px 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: 0.85rem;
background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus {
outline: none;
border-color: var(--color-brand);
}
}
.btn-remove {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 0.8rem;
&:hover {
background: var(--color-danger-100);
color: var(--color-danger-500);
}
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 0.8rem;
&:hover {
background: var(--color-danger-100);
color: var(--color-danger-500);
}
}
/* Prominent Add Button */
.add-more-container {
margin-top: var(--space-2);
margin-top: var(--space-2);
}
.btn-add-more {
width: 100%;
padding: var(--space-3);
background: var(--color-neutral-800);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
&:hover {
background: var(--color-neutral-900);
transform: translateY(-1px);
}
&:active { transform: translateY(0); }
width: 100%;
padding: var(--space-3);
background: var(--color-neutral-800);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
&:hover {
background: var(--color-neutral-900);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.checkbox-row {
display: flex;
align-items: center;
gap: var(--space-3);
height: 100%;
padding-top: var(--space-4);
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--color-brand);
}
label {
font-weight: 500;
cursor: pointer;
}
display: flex;
align-items: center;
gap: var(--space-3);
height: 100%;
padding-top: var(--space-4);
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--color-brand);
}
label {
font-weight: 500;
cursor: pointer;
}
}
/* Progress Bar */
.progress-container {
margin-bottom: var(--space-3);
text-align: center;
width: 100%;
margin-bottom: var(--space-3);
text-align: center;
width: 100%;
}
.progress-bar {
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 0;
position: relative;
width: 100%;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 0;
position: relative;
width: 100%;
}
.progress-fill {
height: 100%;
background: var(--color-brand);
height: 100%;
background: var(--color-brand);
}
.step-warning {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background: var(--color-neutral-100);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background: var(--color-neutral-100);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-weight: 500;
}

View File

@@ -1,6 +1,18 @@
import { Component, input, output, signal, OnInit, inject } from '@angular/core';
import {
Component,
input,
output,
signal,
OnInit,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
@@ -8,22 +20,39 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
import {
QuoteRequest,
QuoteEstimatorService,
OptionsResponse,
SimpleOption,
MaterialOption,
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
file: File;
quantity: number;
color: string;
filamentVariantId?: number;
file: File;
quantity: number;
color: string;
filamentVariantId?: number;
}
@Component({
selector: 'app-upload-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppSelectComponent,
AppDropzoneComponent,
AppButtonComponent,
StlViewerComponent,
ColorSelectorComponent,
],
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
styleUrl: './upload-form.component.scss',
})
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
@@ -55,22 +84,22 @@ export class UploadFormComponent implements OnInit {
currentMaterialVariants = signal<VariantOption[]>([]);
private updateVariants() {
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
} else {
this.currentMaterialVariants.set([]);
}
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find((m) => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
} else {
this.currentMaterialVariants.set([]);
}
}
acceptedFormats = '.stl,.3mf,.step,.stp';
isStepFile(file: File | null): boolean {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.stl');
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.stl');
}
constructor() {
@@ -85,78 +114,140 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
supportEnabled: [false],
});
// Listen to material changes to update variants
this.form.get('material')?.valueChanges.subscribe(() => {
this.updateVariants();
this.updateVariants();
});
this.form.get('quality')?.valueChanges.subscribe((quality) => {
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality);
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality);
});
}
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
const normalized = (quality || 'standard').toLowerCase();
const normalized = (quality || 'standard').toLowerCase();
const presets: Record<string, { nozzleDiameter: number; layerHeight: number; infillDensity: number; infillPattern: string }> = {
standard: { nozzleDiameter: 0.4, layerHeight: 0.2, infillDensity: 15, infillPattern: 'grid' },
extra_fine: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' },
high: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' }, // Legacy alias
draft: { nozzleDiameter: 0.4, layerHeight: 0.24, infillDensity: 12, infillPattern: 'grid' }
};
const presets: Record<
string,
{
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
}
> = {
standard: {
nozzleDiameter: 0.4,
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
},
extra_fine: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
},
high: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
}, // Legacy alias
draft: {
nozzleDiameter: 0.4,
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
},
};
const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false });
const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false });
}
ngOnInit() {
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
this.materials.set(
options.materials.map((m) => ({ label: m.label, value: m.code })),
);
this.qualities.set(
options.qualities.map((q) => ({ label: q.label, value: q.id })),
);
this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
);
this.layerHeights.set(
options.layerHeights.map((l) => ({ label: l.label, value: l.value })),
);
this.nozzleDiameters.set(
options.nozzleDiameters.map((n) => ({
label: n.label,
value: n.value,
})),
);
this.setDefaults();
this.setDefaults();
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([
{
label: this.translate.instant('CALC.FALLBACK_MATERIAL'),
value: 'PLA',
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([{ label: this.translate.instant('CALC.FALLBACK_MATERIAL'), value: 'PLA' }]);
this.qualities.set([{ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), value: 'standard' }]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
}
});
]);
this.qualities.set([
{
label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'),
value: 'standard',
},
]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
},
});
}
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
}
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find((q) => q.value === 'standard');
this.form
.get('quality')
?.setValue(std ? std.value : this.qualities()[0].value);
}
if (
this.nozzleDiameters().length > 0 &&
!this.form.get('nozzleDiameter')?.value
) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (
this.layerHeights().length > 0 &&
!this.form.get('layerHeight')?.value
) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (
this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value
) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
}
onFilesDropped(newFiles: File[]) {
@@ -165,214 +256,233 @@ export class UploadFormComponent implements OnInit {
let hasError = false;
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
} else {
const defaultSelection = this.getDefaultVariantSelection();
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
}
if (file.size > MAX_SIZE) {
hasError = true;
} else {
const defaultSelection = this.getDefaultVariantSelection();
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
});
}
}
if (hasError) {
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
}
if (validItems.length > 0) {
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.items.update((current) => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
onAdditionalFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.onFilesDropped(Array.from(input.files));
// Reset input so same files can be selected again if needed
input.value = '';
}
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.onFilesDropped(Array.from(input.files));
// Reset input so same files can be selected again if needed
input.value = '';
}
}
updateItemQuantityByIndex(index: number, quantity: number) {
if (!Number.isInteger(index) || index < 0) return;
const normalizedQty = this.normalizeQuantity(quantity);
if (!Number.isInteger(index) || index < 0) return;
const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
}
updateItemQuantityByName(fileName: string, quantity: number) {
const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity);
const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => {
let matched = false;
return current.map(item => {
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true;
return { ...item, quantity: normalizedQty };
}
return item;
});
this.items.update((current) => {
let matched = false;
return current.map((item) => {
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true;
return { ...item, quantity: normalizedQty };
}
return item;
});
});
}
selectFile(file: File) {
if (this.selectedFile() === file) {
// toggle off? no, keep active
} else {
this.selectedFile.set(file);
}
if (this.selectedFile() === file) {
// toggle off? no, keep active
} else {
this.selectedFile.set(file);
}
}
// Helper to get color of currently selected file
getSelectedFileColor(): string {
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const item = this.items().find(i => i.file === file);
if (item) {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const found = item.filamentVariantId
? vars.find(v => v.id === item.filamentVariantId)
: vars.find(v => v.colorName === item.color);
if (found) return found.hexColor;
}
return getColorHex(item.color);
const item = this.items().find((i) => i.file === file);
if (item) {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const found = item.filamentVariantId
? vars.find((v) => v.id === item.filamentVariantId)
: vars.find((v) => v.colorName === item.color);
if (found) return found.hexColor;
}
return '#facf0a';
return getColorHex(item.color);
}
return '#facf0a';
}
updateItemQuantity(index: number, event: Event) {
const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1;
this.updateItemQuantityByIndex(index, quantity);
const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1;
this.updateItemQuantityByIndex(index, quantity);
}
updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) {
const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], color: colorName, filamentVariantId };
return updated;
});
updateItemColor(
index: number,
newSelection: string | { colorName: string; filamentVariantId?: number },
) {
const colorName =
typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId =
typeof newSelection === 'string'
? undefined
: newSelection.filamentVariantId;
this.items.update((current) => {
const updated = [...current];
updated[index] = {
...updated[index],
color: colorName,
filamentVariantId,
};
return updated;
});
}
removeItem(index: number) {
this.items.update(current => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
}
return updated;
});
this.items.update((current) => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
}
return updated;
});
}
setFiles(files: File[]) {
const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection();
for (const file of files) {
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
}
const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection();
for (const file of files) {
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
});
}
if (validItems.length > 0) {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
if (validItems.length > 0) {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const preferred = vars.find(v => !v.isOutOfStock) || vars[0];
return {
colorName: preferred.colorName,
filamentVariantId: preferred.id
};
}
return { colorName: 'Black' };
private getDefaultVariantSelection(): {
colorName: string;
filamentVariantId?: number;
} {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
return {
colorName: preferred.colorName,
filamentVariantId: preferred.id,
};
}
return { colorName: 'Black' };
}
private syncItemVariantSelections(): void {
const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) {
return;
}
const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) {
return;
}
const fallback = vars.find(v => !v.isOutOfStock) || vars[0];
this.items.update(current => current.map(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;
return {
...item,
color: selected.colorName,
filamentVariantId: selected.id
};
}));
const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
this.items.update((current) =>
current.map((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;
return {
...item,
color: selected.colorName,
filamentVariantId: selected.id,
};
}),
);
}
patchSettings(settings: any) {
if (!settings) return;
// settings object matches keys in our form?
// Session has: materialCode, etc. derived from QuoteSession entity properties
// We need to map them if names differ.
if (!settings) return;
// settings object matches keys in our form?
// Session has: materialCode, etc. derived from QuoteSession entity properties
// We need to map them if names differ.
const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode;
const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode;
// Heuristic for Quality if not explicitly stored as "draft/standard/high"
// But we stored it in session creation?
// QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill.
// So we might need to deduce it or just set Custom/Advanced.
// But for Easy mode, we want to show "Standard" etc.
// Heuristic for Quality if not explicitly stored as "draft/standard/high"
// But we stored it in session creation?
// QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill.
// So we might need to deduce it or just set Custom/Advanced.
// But for Easy mode, we want to show "Standard" etc.
// Actually, let's look at what we have in QuoteSession.
// layerHeightMm, infillPercent, etc.
// If we are in Easy mode, we might just set the "quality" dropdown to match approx?
// Or if we stored "quality" in notes or separate field? We didn't.
// Actually, let's look at what we have in QuoteSession.
// layerHeightMm, infillPercent, etc.
// If we are in Easy mode, we might just set the "quality" dropdown to match approx?
// Or if we stored "quality" in notes or separate field? We didn't.
// Let's try to reverse map or defaults.
if (settings.layerHeightMm) {
if (settings.layerHeightMm >= 0.24) patch.quality = 'draft';
else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine';
else patch.quality = 'standard';
// Let's try to reverse map or defaults.
if (settings.layerHeightMm) {
if (settings.layerHeightMm >= 0.24) patch.quality = 'draft';
else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine';
else patch.quality = 'standard';
patch.layerHeight = settings.layerHeightMm;
}
patch.layerHeight = settings.layerHeightMm;
}
if (settings.nozzleDiameterMm) patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined) patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes;
if (settings.nozzleDiameterMm)
patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined)
patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes;
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
}
onSubmit() {
@@ -380,20 +490,29 @@ export class UploadFormComponent implements OnInit {
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
console.log(
'UploadFormComponent: Emitting submitRequest',
this.form.value,
);
this.submitRequest.emit({
...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode()
mode: this.mode(),
});
} else {
console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
}
Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key);
if (control?.invalid) {
console.log(
'Invalid Control:',
key,
control.errors,
'Value:',
control.value,
);
}
});
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
@@ -401,17 +520,13 @@ export class UploadFormComponent implements OnInit {
}
private normalizeQuantity(quantity: number): number {
if (!Number.isFinite(quantity) || quantity < 1) {
return 1;
}
return Math.floor(quantity);
if (!Number.isFinite(quantity) || quantity < 1) {
return 1;
}
return Math.floor(quantity);
}
private normalizeFileName(fileName: string): string {
return (fileName || '')
.split(/[\\/]/)
.pop()
?.trim()
.toLowerCase() ?? '';
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
}

View File

@@ -3,25 +3,34 @@
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.TITLE' | translate">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name & Surname -->
<div class="row">
<div class="col-md-6">
<app-input
<app-input
formControlName="name"
[label]="'USER_DETAILS.NAME' | translate"
[label]="'USER_DETAILS.NAME' | translate"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('name')?.invalid && form.get('name')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-6">
<app-input
<app-input
formControlName="surname"
[label]="'USER_DETAILS.SURNAME' | translate"
[label]="'USER_DETAILS.SURNAME' | translate"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('surname')?.invalid && form.get('surname')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
@@ -29,87 +38,117 @@
<!-- Email & Phone -->
<div class="row">
<div class="col-md-6">
<app-input
<app-input
formControlName="email"
[label]="'USER_DETAILS.EMAIL' | translate"
[label]="'USER_DETAILS.EMAIL' | translate"
type="email"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
[error]="
form.get('email')?.invalid && form.get('email')?.touched
? ('COMMON.INVALID_EMAIL' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-6">
<app-input
<app-input
formControlName="phone"
[label]="'USER_DETAILS.PHONE' | translate"
[label]="'USER_DETAILS.PHONE' | translate"
type="tel"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('phone')?.invalid && form.get('phone')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
<!-- Address -->
<app-input
<app-input
formControlName="address"
[label]="'USER_DETAILS.ADDRESS' | translate"
[label]="'USER_DETAILS.ADDRESS' | translate"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('address')?.invalid && form.get('address')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
<!-- Zip & City -->
<div class="row">
<div class="col-md-4">
<app-input
<app-input
formControlName="zip"
[label]="'USER_DETAILS.ZIP' | translate"
[label]="'USER_DETAILS.ZIP' | translate"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('zip')?.invalid && form.get('zip')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-8">
<app-input
<app-input
formControlName="city"
[label]="'USER_DETAILS.CITY' | translate"
[label]="'USER_DETAILS.CITY' | translate"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('city')?.invalid && form.get('city')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
<div class="legal-consent">
<label>
<input type="checkbox" formControlName="acceptLegal">
<input type="checkbox" formControlName="acceptLegal" />
<span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
{{ 'LEGAL.CONSENT.AND' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span>
</label>
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched">
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
<div
class="consent-error"
*ngIf="
form.get('acceptLegal')?.invalid &&
form.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div>
</div>
<div class="actions">
<app-button
type="button"
variant="outline"
(click)="onCancel()">
{{ 'COMMON.BACK' | translate }}
<app-button type="button" variant="outline" (click)="onCancel()">
{{ "COMMON.BACK" | translate }}
</app-button>
<app-button
type="submit"
[disabled]="form.invalid || submitting()">
{{ 'USER_DETAILS.SUBMIT' | translate }}
<app-button type="submit" [disabled]="form.invalid || submitting()">
{{ "USER_DETAILS.SUBMIT" | translate }}
</app-button>
</div>
</form>
</app-card>
</div>
@@ -117,30 +156,38 @@
<!-- Order Summary Column -->
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
<div class="summary-content" *ngIf="quote()">
<div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info">
<span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }}</span>
<span class="item-meta"
>{{ item.material }} -
{{
item.color || ("USER_DETAILS.DEFAULT_COLOR" | translate)
}}</span
>
</div>
<div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price">
<span class="item-total-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</span>
<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 }}
{{ item.unitPrice | currency: "CHF" }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small>
</div>
</div>
<hr>
<hr />
<div class="total-row">
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
<span>{{ "QUOTE.TOTAL" | translate }}</span>
<span class="total-price">{{
quote()!.totalPrice | currency: "CHF"
}}</span>
</div>
</div>
</app-card>
</div>
</div>

View File

@@ -6,15 +6,15 @@
display: flex;
flex-wrap: wrap;
margin: 0 -0.5rem;
> [class*='col-'] {
> [class*="col-"] {
padding: 0 0.5rem;
}
}
.col-md-6 {
width: 100%;
@media (min-width: 768px) {
width: 50%;
}
@@ -22,7 +22,7 @@
.col-md-4 {
width: 100%;
@media (min-width: 768px) {
width: 33.333%;
}
@@ -30,7 +30,7 @@
.col-md-8 {
width: 100%;
@media (min-width: 768px) {
width: 66.666%;
}
@@ -55,7 +55,7 @@
line-height: 1.4;
}
input[type='checkbox'] {
input[type="checkbox"] {
margin-top: 0.2rem;
}
@@ -84,7 +84,7 @@
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: none;
}
@@ -134,8 +134,8 @@
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid rgba(255, 255, 255, 0.2);
.total-price {
color: var(--primary-color, #00C853); // Fallback color
color: var(--primary-color, #00c853); // Fallback color
}
}

View File

@@ -1,6 +1,11 @@
import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
@@ -10,9 +15,16 @@ import { QuoteResult } from '../../services/quote-estimator.service';
@Component({
selector: 'app-user-details',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppCardComponent,
AppInputComponent,
AppButtonComponent,
],
templateUrl: './user-details.component.html',
styleUrl: './user-details.component.scss'
styleUrl: './user-details.component.scss',
})
export class UserDetailsComponent {
quote = input<QuoteResult>();
@@ -31,17 +43,17 @@ export class UserDetailsComponent {
address: ['', Validators.required],
zip: ['', Validators.required],
city: ['', Validators.required],
acceptLegal: [false, Validators.requiredTrue]
acceptLegal: [false, Validators.requiredTrue],
});
}
onSubmit() {
if (this.form.valid) {
this.submitting.set(true);
const orderData = {
customer: this.form.value,
quote: this.quote()
quote: this.quote(),
};
// Simulate API delay

View File

@@ -5,7 +5,12 @@ import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[];
items: {
file: File;
quantity: number;
color?: string;
filamentVariantId?: number;
}[];
material: string;
quality: string;
notes?: string;
@@ -68,306 +73,400 @@ interface BackendQuoteResult {
// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
code: string;
label: string;
variants: VariantOption[];
}
export interface VariantOption {
id: number;
name: string;
colorName: string;
hexColor: string;
finishType: string;
stockSpools: number;
stockFilamentGrams: number;
isOutOfStock: boolean;
id: number;
name: string;
colorName: string;
hexColor: string;
finishType: string;
stockSpools: number;
stockFilamentGrams: number;
isOutOfStock: boolean;
}
export interface QualityOption {
id: string;
label: string;
id: string;
label: string;
}
export interface InfillOption {
id: string;
label: string;
id: string;
label: string;
}
export interface NumericOption {
value: number;
label: string;
value: number;
label: string;
}
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
}
// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
value: string | number;
label: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
private buildEasyModePreset(quality: string | undefined): {
quality: string;
layerHeight: number;
infillDensity: number;
infillPattern: string;
nozzleDiameter: number;
quality: string;
layerHeight: number;
infillDensity: number;
infillPattern: string;
nozzleDiameter: number;
} {
const normalized = (quality || 'standard').toLowerCase();
// Legacy alias support.
if (normalized === 'high' || normalized === 'extra_fine') {
return {
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
nozzleDiameter: 0.4
};
}
if (normalized === 'draft') {
return {
quality: 'extra_fine',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
nozzleDiameter: 0.4
};
}
const normalized = (quality || 'standard').toLowerCase();
// Legacy alias support.
if (normalized === 'high' || normalized === 'extra_fine') {
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
if (normalized === 'draft') {
return {
quality: 'extra_fine',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
tap({
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
})
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
return this.http
.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
headers,
})
.pipe(
tap({
next: (res) =>
console.log('QuoteEstimatorService: Options loaded', res),
error: (err) =>
console.error('QuoteEstimatorService: Options failed', err),
}),
);
}
// NEW METHODS for Order Flow
getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers });
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ headers },
);
}
updateLineItem(lineItemId: string, changes: any): Observable<any> {
const headers: any = {};
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
const headers: any = {};
return this.http.patch(
`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`,
changes,
{ headers },
);
}
createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
const headers: any = {};
return this.http.post(
`${environment.apiUrl}/api/orders/from-quote/${sessionId}`,
orderDetails,
{ headers },
);
}
getOrder(orderId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, {
headers,
});
}
reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
const headers: any = {};
return this.http.post(
`${environment.apiUrl}/api/orders/${orderId}/payments/report`,
{ method },
{ headers },
);
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
{
headers,
responseType: 'blob',
},
);
}
getOrderConfirmation(orderId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/confirmation`,
{
headers,
responseType: 'blob',
},
);
}
getTwintPayment(orderId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, {
headers,
});
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
console.warn('QuoteEstimatorService: No items to calculate');
return of();
console.warn('QuoteEstimatorService: No items to calculate');
return of();
}
return new Observable(observer => {
// 1. Create Session first
const headers: any = {};
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
next: (sessionRes) => {
const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0;
// 2. Upload files to this session
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
return new Observable((observer) => {
// 1. Create Session first
const headers: any = {};
const checkCompletion = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId);
}
};
this.http
.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
.subscribe({
next: (sessionRes) => {
const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0;
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
// 2. Upload files to this session
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const easyPreset = request.mode === 'easy'
? this.buildEasyModePreset(request.quality)
: null;
const settings = {
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter : request.nozzleDiameter
};
const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' });
formData.append('settings', settingsBlob);
const checkCompletion = () => {
const avg = Math.round(
allProgress.reduce((a, b) => a + b, 0) / totalItems,
);
observer.next(avg);
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((100 * event.loaded) / event.total);
checkCompletion();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
completedRequests++;
checkCompletion();
}
},
error: (err) => {
console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name };
completedRequests++;
checkCompletion();
}
});
if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId);
}
};
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
const easyPreset =
request.mode === 'easy'
? this.buildEasyModePreset(request.quality)
: null;
const settings = {
complexityMode:
request.mode === 'easy'
? 'ADVANCED'
: request.mode.toUpperCase(),
material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset
? easyPreset.layerHeight
: request.layerHeight,
infillDensity: easyPreset
? easyPreset.infillDensity
: request.infillDensity,
infillPattern: easyPreset
? easyPreset.infillPattern
: request.infillPattern,
nozzleDiameter: easyPreset
? easyPreset.nozzleDiameter
: request.nozzleDiameter,
};
const settingsBlob = new Blob([JSON.stringify(settings)], {
type: 'application/json',
});
formData.append('settings', settingsBlob);
this.http
.post<any>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`,
formData,
{
headers,
reportProgress: true,
observe: 'events',
},
)
.subscribe({
next: (event) => {
if (
event.type === HttpEventType.UploadProgress &&
event.total
) {
allProgress[index] = Math.round(
(100 * event.loaded) / event.total,
);
checkCompletion();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
finalResponses[index] = {
...event.body,
success: true,
fileName: item.file.name,
originalQty: item.quantity,
originalItem: item,
};
completedRequests++;
checkCompletion();
}
},
error: (err) => {
console.error('Item upload failed', err);
finalResponses[index] = {
success: false,
fileName: item.file.name,
};
completedRequests++;
checkCompletion();
},
});
},
error: (err) => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session');
}
});
},
error: (err) => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session');
},
});
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: (err) => {
console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote');
}
});
};
const finalize = (
responses: any[],
setupCost: number,
sessionId: string,
) => {
this.http
.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
headers,
})
.subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: (err) => {
console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote');
},
});
};
});
}
// Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
private pendingConsultation = signal<{
files: File[];
message: string;
} | null>(null);
setPendingConsultation(data: {files: File[], message: string}) {
this.pendingConsultation.set(data);
setPendingConsultation(data: { files: File[]; message: string }) {
this.pendingConsultation.set(data);
}
getPendingConsultation() {
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
}
// Session File Retrieval
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`,
{
headers,
responseType: 'blob',
},
);
}
mapSessionToQuoteResult(sessionData: any): QuoteResult {
const session = sessionData.session;
const items = sessionData.items || [];
const totalTime = items.reduce((acc: number, item: any) => acc + (item.printTimeSeconds || 0) * item.quantity, 0);
const totalWeight = items.reduce((acc: number, item: any) => acc + (item.materialGrams || 0) * item.quantity, 0);
const session = sessionData.session;
const items = sessionData.items || [];
const totalTime = items.reduce(
(acc: number, item: any) =>
acc + (item.printTimeSeconds || 0) * item.quantity,
0,
);
const totalWeight = items.reduce(
(acc: number, item: any) =>
acc + (item.materialGrams || 0) * item.quantity,
0,
);
return {
sessionId: session.id,
items: items.map((item: any) => ({
id: item.id,
fileName: item.originalFilename,
unitPrice: item.unitPriceChf,
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.
color: item.colorCode,
filamentVariantId: item.filamentVariantId
})),
return {
sessionId: session.id,
items: items.map((item: any) => ({
id: item.id,
fileName: item.originalFilename,
unitPrice: item.unitPriceChf,
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.
color: item.colorCode,
filamentVariantId: item.filamentVariantId,
})),
setupCost: session.setupCostChf || 0,
globalMachineCost: sessionData.globalMachineCostChf || 0,
currency: 'CHF', // Fixed for now
totalPrice: (sessionData.itemsTotalChf || 0) + (session.setupCostChf || 0) + (sessionData.shippingCostChf || 0),
totalPrice:
(sessionData.itemsTotalChf || 0) +
(session.setupCostChf || 0) +
(sessionData.shippingCostChf || 0),
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: session.notes
};
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: session.notes,
};
}
}