diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 540405d..ce4261d 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -26,6 +26,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; @RestController @RequestMapping("/api/quote-sessions") @@ -309,4 +311,34 @@ public class QuoteSessionController { return ResponseEntity.ok(response); } + + // 6. Download Line Item Content + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") + public ResponseEntity downloadLineItemContent( + @PathVariable UUID sessionId, + @PathVariable UUID lineItemId + ) throws IOException { + QuoteLineItem item = lineItemRepo.findById(lineItemId) + .orElseThrow(() -> new RuntimeException("Item not found")); + + if (!item.getQuoteSession().getId().equals(sessionId)) { + return ResponseEntity.badRequest().build(); + } + + if (item.getStoredPath() == null) { + return ResponseEntity.notFound().build(); + } + + Path path = Paths.get(item.getStoredPath()); + if (!Files.exists(path)) { + return ResponseEntity.notFound().build(); + } + + org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri()); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") + .body(resource); + } } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 4b0e196..4a3d4c0 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -1,6 +1,8 @@ import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; +import { forkJoin } from 'rxjs'; +import { map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; @@ -43,6 +45,95 @@ export class CalculatorPageComponent implements OnInit { this.mode.set(data['mode']); } }); + + this.route.queryParams.subscribe(params => { + const sessionId = params['session']; + if (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); + 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.error.set(true); + 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 => + 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, item.colorCode); + } + }); + } + }); + } + 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) { @@ -67,10 +158,21 @@ export class CalculatorPageComponent implements OnInit { this.uploadProgress.set(event); } else { // It's the result - this.result.set(event as QuoteResult); + const res = event as QuoteResult; + 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: () => { diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 726a4a5..917284c 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -232,6 +232,59 @@ export class UploadFormComponent implements OnInit { }); } + setFiles(files: File[]) { + const validItems: FormItem[] = []; + for (const file of files) { + // Default color is Black or derive from somewhere if possible, but here we just init + validItems.push({ file, quantity: 1, color: 'Black' }); + } + + 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); + } + } + + 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. + + 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. + + // 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.28) patch.quality = 'draft'; + else if (settings.layerHeightMm <= 0.12) patch.quality = 'high'; + else patch.quality = 'standard'; + + 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; + + this.form.patchValue(patch); + } + onSubmit() { console.log('UploadFormComponent: onSubmit triggered'); console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 496ab32..9d8668d 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -306,4 +306,45 @@ export class QuoteEstimatorService { this.pendingConsultation.set(null); // Clear after reading return data; } + + // Session File Retrieval + getLineItemContent(sessionId: string, lineItemId: string): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + 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); + + 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 + })), + setupCost: session.setupCostChf, + currency: 'CHF', // Fixed for now + totalPrice: sessionData.grandTotalChf, + totalTimeHours: Math.floor(totalTime / 3600), + totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), + totalWeight: Math.ceil(totalWeight), + notes: session.notes + }; + } }