style: apply prettier formatting
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"> </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"
|
||||
> </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>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user