import { Component, computed, signal, ViewChild, ElementRef, OnInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.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 { SuccessStateComponent } from '../../shared/components/success-state/success-state.component'; import { Router, ActivatedRoute } from '@angular/router'; import { LanguageService } from '../../core/services/language.service'; type TrackedPrintSettings = { mode: 'easy' | 'advanced'; material: string; quality: string; nozzleDiameter: number; layerHeight: number; infillDensity: number; infillPattern: string; supportEnabled: boolean; }; @Component({ selector: 'app-calculator-page', standalone: true, imports: [ CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, AppButtonComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent, ], templateUrl: './calculator-page.component.html', styleUrl: './calculator-page.component.scss', }) export class CalculatorPageComponent implements OnInit { mode = signal<'easy' | 'advanced'>('easy'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); loading = signal(false); uploadProgress = signal(0); result = signal(null); cadSessionLocked = signal(false); error = signal(false); errorKey = signal('CALC.ERROR_GENERIC'); isZeroQuoteError = computed( () => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE', ); orderSuccess = signal(false); requiresRecalculation = signal(false); itemSettingsDiffByFileName = signal< Record >({}); private baselinePrintSettings: TrackedPrintSettings | null = null; private baselineItemSettingsByFileName = new Map< string, TrackedPrintSettings >(); @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; constructor( private estimator: QuoteEstimatorService, private router: Router, private route: ActivatedRoute, private languageService: LanguageService, ) {} ngOnInit() { 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); } } }); } 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.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(result); this.baselinePrintSettings = this.toTrackedSettingsFromSession( data.session, ); this.baselineItemSettingsByFileName = this.buildBaselineMapFromSession( data.items || [], this.baselinePrintSettings, ); this.requiresRecalculation.set(false); this.itemSettingsDiffByFileName.set({}); const isCadSession = data?.session?.status === 'CAD_ACTIVE'; this.cadSessionLocked.set(isCadSession); this.step.set('quote'); // 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; } // Download all files const downloads = items.map((item) => forkJoin({ originalBlob: this.estimator.getLineItemContent(session.id, item.id), previewBlob: this.estimator .getLineItemContent(session.id, item.id, true) .pipe(catchError(() => of(null))), }).pipe( map(({ originalBlob, previewBlob }) => { return { originalBlob, previewBlob, fileName: item.originalFilename, hasConvertedPreview: !!item.convertedStoredPath, }; }), ), ); forkJoin(downloads).subscribe({ next: (results: any[]) => { const files = results.map( (res) => new File([res.originalBlob], res.fileName, { type: 'application/octet-stream', }), ); if (this.uploadForm) { this.uploadForm.setFiles(files); results.forEach((res, index) => { if (!res.hasConvertedPreview || !res.previewBlob) { return; } const previewName = res.fileName .replace(/\.[^.]+$/, '') .concat('.stl'); const previewFile = new File([res.previewBlob], previewName, { type: 'model/stl', }); this.uploadForm.setPreviewFileByIndex(index, previewFile); }); this.uploadForm.patchSettings(session); items.forEach((item, index) => { const tracked = this.toTrackedSettingsFromSessionItem( item, this.toTrackedSettingsFromSession(session), ); this.uploadForm.setItemPrintSettingsByIndex(index, { material: tracked.material.toUpperCase(), quality: tracked.quality, nozzleDiameter: tracked.nozzleDiameter, layerHeight: tracked.layerHeight, infillDensity: tracked.infillDensity, infillPattern: tracked.infillPattern, supportEnabled: tracked.supportEnabled, }); if (item.colorCode) { this.uploadForm.updateItemColor(index, { colorName: item.colorCode, filamentVariantId: item.filamentVariantId, }); } }); const selected = this.uploadForm.selectedFile(); if (selected) { this.uploadForm.selectFile(selected); } } this.loading.set(false); }, error: (err: any) => { console.error('Failed to download files', err); this.loading.set(false); // Still show result? Yes. }, }); } onCalculate(req: QuoteRequest) { // ... (logic remains the same, simplified for diff) this.currentRequest = req; this.loading.set(true); this.uploadProgress.set(0); this.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(null); this.cadSessionLocked.set(false); this.orderSuccess.set(false); // Auto-scroll on mobile to make analysis visible setTimeout(() => { 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); } 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); this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req); this.baselineItemSettingsByFileName = this.buildBaselineMapFromRequest(req); this.requiresRecalculation.set(false); this.itemSettingsDiffByFileName.set({}); this.loading.set(false); this.uploadProgress.set(100); this.step.set('quote'); // 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.estimator.getQuoteSession(res.sessionId).subscribe({ next: (sessionData) => { this.restoreFilesAndSettings( sessionData.session, sessionData.items || [], ); }, error: (err) => { console.warn('Failed to refresh files for preview', err); }, }); } } }, error: () => { this.setQuoteError('CALC.ERROR_GENERIC'); this.loading.set(false); }, }); } onProceed() { const res = this.result(); if (res && res.sessionId) { const segments = this.cadSessionLocked() ? ['/', this.languageService.selectedLang(), 'checkout', 'cad'] : ['/', this.languageService.selectedLang(), 'checkout']; this.router.navigate(segments, { queryParams: { session: res.sessionId }, }); } else { console.error('No session ID found in quote result'); // Fallback or error handling } } onCancelDetails() { this.step.set('quote'); } onItemChange(event: { id?: string; index: number; fileName: string; quantity: number; source?: 'left' | 'right'; }) { // 1. Update local form for consistency (UI feedback) if (event.source !== 'left' && 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; 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; 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 refresh session totals', err); }, }); }, error: (err) => { console.error('Failed to update line item', err); }, }); } } onUploadItemQuantityChange(event: { index: number; fileName: string; quantity: number; }) { const resultItems = this.result()?.items || []; const byIndex = resultItems[event.index]; const byName = resultItems.find((item) => item.fileName === event.fileName); const id = byIndex?.id ?? byName?.id; this.onItemChange({ ...event, id, source: 'left', }); } onQuoteItemQuantityPreviewChange(event: { index: number; fileName: string; quantity: number; }) { if (!this.uploadForm) return; this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); } onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); this.step.set('success'); } onNewQuote() { this.step.set('upload'); this.result.set(null); this.requiresRecalculation.set(false); this.itemSettingsDiffByFileName.set({}); this.baselinePrintSettings = null; this.baselineItemSettingsByFileName = new Map< string, TrackedPrintSettings >(); this.cadSessionLocked.set(false); this.orderSuccess.set(false); this.switchMode('easy'); // Reset to default and sync URL } private currentRequest: QuoteRequest | null = null; onUploadPrintSettingsChange(_: TrackedPrintSettings) { void _; if (!this.result()) return; this.refreshRecalculationRequirement(); } onItemSettingsDiffChange( diffByFileName: Record, ) { this.itemSettingsDiffByFileName.set(diffByFileName || {}); } onConsult() { const currentFormRequest = this.uploadForm?.getCurrentRequestDraft(); const req = currentFormRequest ?? this.currentRequest; if (!req) { this.router.navigate([ '/', this.languageService.selectedLang(), 'contact', ]); return; } 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`; }); if (req.mode === 'advanced') { 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, }); this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); } private isInvalidQuote(result: QuoteResult): boolean { const invalidPrice = !Number.isFinite(result.totalPrice) || result.totalPrice <= 0; const invalidWeight = !Number.isFinite(result.totalWeight) || result.totalWeight <= 0; const invalidTime = !Number.isFinite(result.totalTimeHours) || !Number.isFinite(result.totalTimeMinutes) || (result.totalTimeHours <= 0 && result.totalTimeMinutes <= 0); return invalidPrice || invalidWeight || invalidTime; } private setQuoteError(key: string): void { this.errorKey.set(key); this.error.set(true); this.result.set(null); this.requiresRecalculation.set(false); this.itemSettingsDiffByFileName.set({}); this.baselinePrintSettings = null; this.baselineItemSettingsByFileName = new Map< string, TrackedPrintSettings >(); } switchMode(nextMode: 'easy' | 'advanced'): void { if (this.cadSessionLocked()) return; const targetPath = nextMode === 'easy' ? 'basic' : 'advanced'; const currentPath = this.route.snapshot.routeConfig?.path; this.mode.set(nextMode); if (currentPath === targetPath) { return; } this.router.navigate(['..', targetPath], { relativeTo: this.route, queryParamsHandling: 'preserve', }); } private toTrackedSettingsFromRequest( req: QuoteRequest, ): TrackedPrintSettings { return { mode: req.mode, material: this.normalizeString(req.material || 'PLA'), quality: this.normalizeString(req.quality || 'standard'), nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2), layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3), infillDensity: this.normalizeNumber(req.infillDensity, 20, 2), infillPattern: this.normalizeString(req.infillPattern || 'grid'), supportEnabled: Boolean(req.supportEnabled), }; } private toTrackedSettingsFromItem( req: QuoteRequest, item: QuoteRequest['items'][number], ): TrackedPrintSettings { return { mode: req.mode, material: this.normalizeString(item.material || req.material || 'PLA'), quality: this.normalizeString(item.quality || req.quality || 'standard'), nozzleDiameter: this.normalizeNumber( item.nozzleDiameter ?? req.nozzleDiameter, 0.4, 2, ), layerHeight: this.normalizeNumber( item.layerHeight ?? req.layerHeight, 0.2, 3, ), infillDensity: this.normalizeNumber( item.infillDensity ?? req.infillDensity, 20, 2, ), infillPattern: this.normalizeString( item.infillPattern || req.infillPattern || 'grid', ), supportEnabled: Boolean(item.supportEnabled ?? req.supportEnabled), }; } private toTrackedSettingsFromSession(session: any): TrackedPrintSettings { const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3); return { mode: this.mode(), material: this.normalizeString(session?.materialCode || 'PLA'), quality: layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard', nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2), layerHeight: layer, infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2), infillPattern: this.normalizeString(session?.infillPattern || 'grid'), supportEnabled: Boolean(session?.supportsEnabled), }; } private toTrackedSettingsFromSessionItem( item: any, fallback: TrackedPrintSettings, ): TrackedPrintSettings { const layer = this.normalizeNumber( item?.layerHeightMm, fallback.layerHeight, 3, ); return { mode: this.mode(), material: this.normalizeString(item?.materialCode || fallback.material), quality: this.normalizeString( item?.quality || (layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'), ), nozzleDiameter: this.normalizeNumber( item?.nozzleDiameterMm, fallback.nozzleDiameter, 2, ), layerHeight: layer, infillDensity: this.normalizeNumber( item?.infillPercent, fallback.infillDensity, 2, ), infillPattern: this.normalizeString( item?.infillPattern || fallback.infillPattern, ), supportEnabled: Boolean(item?.supportsEnabled ?? fallback.supportEnabled), }; } private buildBaselineMapFromRequest( req: QuoteRequest, ): Map { const map = new Map(); req.items.forEach((item) => { map.set( this.normalizeFileName(item.file?.name || ''), this.toTrackedSettingsFromItem(req, item), ); }); return map; } private buildBaselineMapFromSession( items: any[], defaultSettings: TrackedPrintSettings | null, ): Map { const map = new Map(); const fallback = defaultSettings ?? this.defaultTrackedSettings(); items.forEach((item) => { map.set( this.normalizeFileName(item?.originalFilename || ''), this.toTrackedSettingsFromSessionItem(item, fallback), ); }); return map; } private defaultTrackedSettings(): TrackedPrintSettings { return { mode: this.mode(), material: 'pla', quality: 'standard', nozzleDiameter: 0.4, layerHeight: 0.2, infillDensity: 20, infillPattern: 'grid', supportEnabled: false, }; } private refreshRecalculationRequirement(): void { if (!this.result()) return; const draft = this.uploadForm?.getCurrentRequestDraft(); if (!draft || draft.items.length === 0) { this.requiresRecalculation.set(false); return; } const fallback = this.baselinePrintSettings; if (!fallback) { this.requiresRecalculation.set(false); return; } const changed = draft.items.some((item) => { const key = this.normalizeFileName(item.file?.name || ''); const baseline = this.baselineItemSettingsByFileName.get(key) || fallback; const current = this.toTrackedSettingsFromItem(draft, item); return !this.sameTrackedSettings(baseline, current); }); this.requiresRecalculation.set(changed); } private sameTrackedSettings( a: TrackedPrintSettings, b: TrackedPrintSettings, ): boolean { return ( a.mode === b.mode && a.material === this.normalizeString(b.material) && a.quality === this.normalizeString(b.quality) && Math.abs( a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2), ) < 0.0001 && Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) < 0.0001 && Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) < 0.0001 && a.infillPattern === this.normalizeString(b.infillPattern) && a.supportEnabled === Boolean(b.supportEnabled) ); } private normalizeFileName(fileName: string): string { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } private normalizeString(value: string): string { return String(value || '') .trim() .toLowerCase(); } private normalizeNumber( value: unknown, fallback: number, decimals: number, ): number { const numeric = Number(value); const resolved = Number.isFinite(numeric) ? numeric : fallback; const factor = 10 ** decimals; return Math.round(resolved * factor) / factor; } }