From 257c60fa5ea5929378effd51c00055877f00617d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 17:07:26 +0100 Subject: [PATCH] feat(back-end): refactor session creation --- backend/build.gradle | 2 + .../printcalculator/config/CorsConfig.java | 3 +- .../CustomQuoteRequestController.java | 29 +- .../controller/QuoteSessionController.java | 176 +++++++--- .../printcalculator/dto/PrintSettingsDto.java | 23 ++ .../printcalculator/dto/QuoteRequestDto.java | 15 + .../core/services/quote-request.service.ts | 40 +++ .../services/quote-estimator.service.ts | 317 +++++++----------- .../contact-form/contact-form.component.ts | 38 ++- 9 files changed, 367 insertions(+), 276 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java create mode 100644 frontend/src/app/core/services/quote-request.service.ts diff --git a/backend/build.gradle b/backend/build.gradle index 35b1154..70e9613 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,6 +29,8 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index 7d15114..b3a9869 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -17,9 +17,10 @@ public class CorsConfig implements WebMvcConfigurer { "http://localhost:80", "http://127.0.0.1", "https://dev.3d-fab.ch", + "https://int.3d-fab.ch", "https://3d-fab.ch" ) - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") .allowCredentials(true); } diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 56cc1a7..1d25df6 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -40,29 +40,20 @@ public class CustomQuoteRequestController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity createCustomQuoteRequest( - // Form fields - @RequestParam("requestType") String requestType, - @RequestParam("customerType") String customerType, - @RequestParam("email") String email, - @RequestParam(value = "phone", required = false) String phone, - @RequestParam(value = "name", required = false) String name, - @RequestParam(value = "companyName", required = false) String companyName, - @RequestParam(value = "contactPerson", required = false) String contactPerson, - @RequestParam("message") String message, - // Files (Max 15) - @RequestParam(value = "files", required = false) List files + @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, + @RequestPart(value = "files", required = false) List files ) throws IOException { // 1. Create Request CustomQuoteRequest request = new CustomQuoteRequest(); - request.setRequestType(requestType); - request.setCustomerType(customerType); - request.setEmail(email); - request.setPhone(phone); - request.setName(name); - request.setCompanyName(companyName); - request.setContactPerson(contactPerson); - request.setMessage(message); + request.setRequestType(requestDto.getRequestType()); + request.setCustomerType(requestDto.getCustomerType()); + request.setEmail(requestDto.getEmail()); + request.setPhone(requestDto.getPhone()); + request.setName(requestDto.getName()); + request.setCompanyName(requestDto.getCompanyName()); + request.setContactPerson(requestDto.getContactPerson()); + request.setMessage(requestDto.getMessage()); request.setStatus("PENDING"); request.setCreatedAt(OffsetDateTime.now()); request.setUpdatedAt(OffsetDateTime.now()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 618338c..0aad79f 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -28,7 +28,7 @@ import java.util.UUID; @RestController @RequestMapping("/api/quote-sessions") -@CrossOrigin(origins = "*") // Allow CORS for dev + public class QuoteSessionController { private final QuoteSessionRepository sessionRepo; @@ -36,6 +36,7 @@ public class QuoteSessionController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; + private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Defaults private static final String DEFAULT_FILAMENT = "pla_basic"; @@ -45,49 +46,34 @@ public class QuoteSessionController { QuoteLineItemRepository lineItemRepo, SlicerService slicerService, QuoteCalculator quoteCalculator, - PrinterMachineRepository machineRepo) { + PrinterMachineRepository machineRepo, + com.printcalculator.repository.PricingPolicyRepository pricingRepo) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; + this.pricingRepo = pricingRepo; } - // 1. Start a new session with a file - @PostMapping(value = "/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + // 1. Start a new empty session + @PostMapping(value = "") @Transactional - public ResponseEntity createSessionAndAddItem( - @RequestParam("file") MultipartFile file - ) throws IOException { - // Create new session + public ResponseEntity createSession() { QuoteSession session = new QuoteSession(); session.setStatus("ACTIVE"); - session.setPricingVersion("v1"); // Placeholder - session.setMaterialCode(DEFAULT_FILAMENT); // Default for session + session.setPricingVersion("v1"); + // Default material/settings will be set when items are added or updated? + // For now set safe defaults + session.setMaterialCode("pla_basic"); session.setSupportsEnabled(false); session.setCreatedAt(OffsetDateTime.now()); session.setExpiresAt(OffsetDateTime.now().plusDays(30)); - // Set defaults - session.setSetupCostChf(BigDecimal.ZERO); + + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO); session = sessionRepo.save(session); - - // Process file and add item - addItemToSession(session, file); - - // Refresh session to return updated data (if we added list fetching to repo, otherwise manually fetch items if needed for response) - // For now, let's just return the session. The client might need to fetch items separately or we can return a DTO. - // User request: "ritorna sessione + line items + total" - // Since QuoteSession entity doesn't have a @OneToMany list of items (it has OneToMany usually but mapped by item), - // we might need a DTO or just rely on the fact that we might add the list to the entity if valid. - // Looking at QuoteSession.java, it does NOT have a list of items. - // So we should probably return a DTO or just return the Session and Client calls GET /quote-sessions/{id} immediately? - // User request: "ritorna quoteSessionId" (actually implies just ID, but likely wants full object). - // "ritorna sessione + line items + total (usa view o calcolo service)" refers to GET /quote-sessions/{id} - - // Let's return the full session details including items in a DTO/Map/wrapper? - // Or just the session for now. The user said "ritorna quoteSessionId" for this specific endpoint. - // Let's return the Session entity for now. return ResponseEntity.ok(session); } @@ -96,17 +82,18 @@ public class QuoteSessionController { @Transactional public ResponseEntity addItemToExistingSession( @PathVariable UUID id, - @RequestParam("file") MultipartFile file + @RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, + @RequestPart("file") MultipartFile file ) throws IOException { QuoteSession session = sessionRepo.findById(id) .orElseThrow(() -> new RuntimeException("Session not found")); - QuoteLineItem item = addItemToSession(session, file); + QuoteLineItem item = addItemToSession(session, file, settings); return ResponseEntity.ok(item); } // Helper to add item - private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file) throws IOException { + private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IOException("File is empty"); // 1. Save file temporarily @@ -114,35 +101,87 @@ public class QuoteSessionController { file.transferTo(tempInput.toFile()); try { - // 2. Mock Calc or Real Calc - // The user said: "per ora calcolo mock" (mock calculation) but we have SlicerService. - // "Nota: il calcolo può essere stub: set print_time_seconds/material_grams/unit_price_chf a valori placeholder." - // However, since we have the SlicerService, we CAN try to use it if we want, OR just use stub as requested to be fast? - // "avvia calcolo (per ora calcolo mock)" -> I will use a simple Stub to satisfy the requirement immediately. - // But I will also implement the structure to swap to Real Calc. + // Apply Basic/Advanced Logic + applyPrintSettings(settings); + + // REAL SLICING + // 1. Pick Machine (default to first active or specific) + PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() + .orElseThrow(() -> new RuntimeException("No active printer found")); - // STUB CALCULATION as requested - int printTime = 3600; // 1 hour - BigDecimal materialGrams = new BigDecimal("50.00"); - BigDecimal unitPrice = new BigDecimal("15.00"); + // 2. Pick Profiles + String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" + // If the display name doesn't match the json profile name, we might need a mapping key in DB. + // For now assuming display name works or we use a tough default + machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists. + // Ideally: machine.getSlicerProfileName(); - // 3. Create Line Item + String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA"); + // Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG" + if (settings.getMaterial() != null) { + if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA"; + else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG"; + else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU"; + else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS"; + } + + String processProfile = "0.20mm Standard @BBL A1"; + // Mapping quality to process + // "standard" -> "0.20mm Standard @BBL A1" + // "draft" -> "0.28mm Extra Draft @BBL A1" + // "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca) + // Let's use robust defaults or simple overrides + if (settings.getLayerHeight() != null) { + if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1"; + else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1"; + } + + // Build overrides map from settings + Map processOverrides = new HashMap<>(); + if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); + if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); + if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); + + // 3. Slice + PrintStats stats = slicerService.slice( + tempInput.toFile(), + machineProfile, + filamentProfile, + processProfile, + null, // machine overrides + processOverrides + ); + + // 4. Calculate Quote + QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); + + // 5. Create Line Item QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); item.setOriginalFilename(file.getOriginalFilename()); item.setQuantity(1); - item.setColorCode("#FFFFFF"); // Default - item.setStatus("CALCULATED"); + item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); + item.setStatus("READY"); // or CALCULATED - item.setPrintTimeSeconds(printTime); - item.setMaterialGrams(materialGrams); - item.setUnitPriceChf(unitPrice); - item.setPricingBreakdown(Map.of("mock", true)); + item.setPrintTimeSeconds((int) stats.printTimeSeconds()); + item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); + item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); - // Set simple bounding box - item.setBoundingBoxXMm(BigDecimal.valueOf(100)); - item.setBoundingBoxYMm(BigDecimal.valueOf(100)); - item.setBoundingBoxZMm(BigDecimal.valueOf(20)); + // Store breakdown + Map breakdown = new HashMap<>(); + breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation? + // Better: QuoteResult could expose detailed breakdown. For now just storing what we have. + breakdown.put("setup_fee", result.getSetupCost()); + item.setPricingBreakdown(breakdown); + + // Dimensions + // Cannot get bb from GCodeParser yet? + // If GCodeParser doesn't return size, we might defaults or 0. + // Stats has filament used. + // Let's set dummy for now or upgrade parser later. + item.setBoundingBoxXMm(BigDecimal.ZERO); + item.setBoundingBoxYMm(BigDecimal.ZERO); + item.setBoundingBoxZMm(BigDecimal.ZERO); item.setCreatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now()); @@ -154,6 +193,37 @@ public class QuoteSessionController { } } + private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { + if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { + // Set defaults based on Quality + String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; + + switch (quality) { + case "draft": + settings.setLayerHeight(0.28); + settings.setInfillDensity(15.0); + settings.setInfillPattern("grid"); + break; + case "high": + settings.setLayerHeight(0.12); + settings.setInfillDensity(20.0); + settings.setInfillPattern("gyroid"); + break; + case "standard": + default: + settings.setLayerHeight(0.20); + settings.setInfillDensity(20.0); + settings.setInfillPattern("grid"); + break; + } + } else { + // ADVANCED Mode: Use values from Frontend, set defaults if missing + if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); + if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); + if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); + } + } + // 3. Update Line Item @PatchMapping("/line-items/{lineItemId}") @Transactional diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java new file mode 100644 index 0000000..c63565d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -0,0 +1,23 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class PrintSettingsDto { + // Mode: "BASIC" or "ADVANCED" + private String complexityMode; + + // Common + private String material; // e.g. "PLA", "PETG" + private String color; // e.g. "White", "#FFFFFF" + + // Basic Mode + private String quality; // "draft", "standard", "high" + + // Advanced Mode (Optional in Basic) + private Double layerHeight; + private Double infillDensity; + private String infillPattern; + private Boolean supportsEnabled; + private String notes; +} diff --git a/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java new file mode 100644 index 0000000..bd03f4d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java @@ -0,0 +1,15 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class QuoteRequestDto { + private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" + private String customerType; // "PRIVATE" or "BUSINESS" + private String email; + private String phone; + private String name; + private String companyName; + private String contactPerson; + private String message; +} diff --git a/frontend/src/app/core/services/quote-request.service.ts b/frontend/src/app/core/services/quote-request.service.ts new file mode 100644 index 0000000..e121b37 --- /dev/null +++ b/frontend/src/app/core/services/quote-request.service.ts @@ -0,0 +1,40 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +export interface QuoteRequestDto { + requestType: string; + customerType: string; + email: string; + phone?: string; + name?: string; + companyName?: string; + contactPerson?: string; + message: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class QuoteRequestService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/api/custom-quote-requests`; + + createRequest(request: QuoteRequestDto, files: File[]): Observable { + const formData = new FormData(); + + // Append Request DTO as JSON Blob + const requestBlob = new Blob([JSON.stringify(request)], { + type: 'application/json' + }); + formData.append('request', requestBlob); + + // Append Files + files.forEach(file => { + formData.append('files', file); + }); + + return this.http.post(this.apiUrl, formData); + } +} 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 fccb3ac..5298e17 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -128,203 +128,135 @@ export class QuoteEstimatorService { } return new Observable(observer => { - const totalItems = request.items.length; - const allProgress: number[] = new Array(totalItems).fill(0); - const finalResponses: any[] = []; - let completedRequests = 0; + // 1. Create Session first + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - const uploads = request.items.map((item, index) => { - const formData = new FormData(); - formData.append('file', item.file); - // machine param removed - backend uses default active - - // Map material? Or trust frontend to send correct code? - // Since we fetch options now, we should send the code directly. - // But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it. - // If frontend sends 'PLA', mapMaterial returns 'pla_basic'. - // We should check if request.material is already a code from options. - // For now, let's assume request.material IS the code if it matches our new options, - // or fallback to mapper if it's old legacy string. - // Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes. - // For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'. - // Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA'). - // Backend expects 'pla_basic' or just 'PLA'? - // QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'. - // So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code. - // Backend OptionsController returns type.getMaterialCode() which is 'PLA'. - // So we still need mapping to slicer profile names. - - formData.append('filament', this.mapMaterial(request.material)); - formData.append('quality', this.mapQuality(request.quality)); - - // Send color for both modes if present, defaulting to Black - formData.append('material_color', item.color || 'Black'); + this.http.post(`${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; - if (request.mode === 'advanced') { - if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); - if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); - if (request.supportEnabled) formData.append('support_enabled', 'true'); - if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString()); - if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString()); + const checkCompletion = () => { + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); + observer.next(avg); + + if (completedRequests === totalItems) { + finalize(finalResponses, sessionSetupCost); + } + }; + + request.items.forEach((item, index) => { + const formData = new FormData(); + formData.append('file', item.file); + + const settings = { + complexityMode: request.mode.toUpperCase(), + material: this.mapMaterial(request.material), + quality: request.quality, + supportsEnabled: request.supportEnabled, + color: item.color || '#FFFFFF', + layerHeight: request.mode === 'advanced' ? request.layerHeight : null, + infillDensity: request.mode === 'advanced' ? request.infillDensity : null, + infillPattern: request.mode === 'advanced' ? request.infillPattern : null, + nozzleDiameter: request.mode === 'advanced' ? request.nozzleDiameter : null + }; + + const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' }); + formData.append('settings', settingsBlob); + + this.http.post(`${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'); + } + }); + + const finalize = (responses: any[], setupCost: number) => { + observer.next(100); + const items: QuoteItem[] = []; + let grandTotal = 0; + let totalTime = 0; + let totalWeight = 0; + let validCount = 0; + + responses.forEach((res, idx) => { + if (!res || !res.success) return; + validCount++; + + const unitPrice = res.unitPriceChf || 0; + const quantity = res.originalQty || 1; + + items.push({ + fileName: res.fileName, + unitPrice: unitPrice, + unitTime: res.printTimeSeconds || 0, + unitWeight: res.materialGrams || 0, + quantity: quantity, + material: request.material, + color: res.originalItem.color || 'Default' + }); + + grandTotal += unitPrice * quantity; + totalTime += (res.printTimeSeconds || 0) * quantity; + totalWeight += (res.materialGrams || 0) * quantity; + }); + + if (validCount === 0) { + observer.error('All calculations failed.'); + return; } - const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + grandTotal += setupCost; - return this.http.post(`${environment.apiUrl}/api/quote`, formData, { - headers, - reportProgress: true, - observe: 'events' - }).pipe( - map(event => ({ item, event, index })), - catchError(err => of({ item, error: err, index })) - ); - }); - - // Subscribe to all - uploads.forEach((obs) => { - obs.subscribe({ - next: (wrapper: any) => { - const idx = wrapper.index; - - if (wrapper.error) { - finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; - } - - const event = wrapper.event; - if (event && event.type === HttpEventType.UploadProgress) { - if (event.total) { - const percent = Math.round((100 * event.loaded) / event.total); - allProgress[idx] = percent; - // Emit average progress - const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); - observer.next(avg); - } - } else if ((event && event.type === HttpEventType.Response) || wrapper.error) { - // It's done (either response or error caught above) - if (!finalResponses[idx]) { // only if not already set by error - allProgress[idx] = 100; - if (wrapper.error) { - finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; - } else { - finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity }; - } - completedRequests++; - } - - if (completedRequests === totalItems) { - // All done - observer.next(100); - - // Calculate Results - let setupCost = 10; - let setupCostFromBackend: number | null = null; - let currencyFromBackend: string | null = null; - - if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) { - setupCost += 2; - } - - const items: QuoteItem[] = []; - - finalResponses.forEach((res, idx) => { - if (!res) return; - const originalItem = request.items[idx]; - const normalized = this.normalizeResponse(res); - if (!normalized.success) return; - - if (normalized.currency && currencyFromBackend == null) { - currencyFromBackend = normalized.currency; - } - if (normalized.setupCost != null && setupCostFromBackend == null) { - setupCostFromBackend = normalized.setupCost; - } - - items.push({ - fileName: res.fileName, - unitPrice: normalized.unitPrice, - unitTime: normalized.unitTime, - unitWeight: normalized.unitWeight, - quantity: res.originalQty, // Use the requested quantity - material: request.material, - color: originalItem.color || 'Default' - }); - }); - - if (items.length === 0) { - observer.error('All calculations failed.'); - return; - } - - // Initial Aggregation - const useBackendSetup = setupCostFromBackend != null; - let grandTotal = useBackendSetup ? 0 : setupCost; - let totalTime = 0; - let totalWeight = 0; - - items.forEach(item => { - grandTotal += item.unitPrice * item.quantity; - totalTime += item.unitTime * item.quantity; - totalWeight += item.unitWeight * item.quantity; - }); - - const totalHours = Math.floor(totalTime / 3600); - const totalMinutes = Math.ceil((totalTime % 3600) / 60); - - const result: QuoteResult = { - items, - setupCost: useBackendSetup ? setupCostFromBackend! : setupCost, - currency: currencyFromBackend || 'CHF', - totalPrice: Math.round(grandTotal * 100) / 100, - totalTimeHours: totalHours, - totalTimeMinutes: totalMinutes, - totalWeight: Math.ceil(totalWeight), - notes: request.notes - }; - - observer.next(result); - observer.complete(); - } - } - }, - error: (err) => { - console.error('Error in request subscription', err); - completedRequests++; - if (completedRequests === totalItems) { - observer.error('Requests failed'); - } - } - }); - }); + const result: QuoteResult = { + items, + setupCost: setupCost, + currency: 'CHF', + totalPrice: Math.round(grandTotal * 100) / 100, + totalTimeHours: Math.floor(totalTime / 3600), + totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), + totalWeight: Math.ceil(totalWeight), + notes: request.notes + }; + + observer.next(result); + observer.complete(); + }; }); } - private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } { - if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') { - return { - success: true, - unitPrice: res.totalPrice, - unitTime: res.stats.printTimeSeconds, - unitWeight: res.stats.filamentWeightGrams, - setupCost: res.setupCost, - currency: res.currency - }; - } - - if (res && res.success && res.data) { - return { - success: true, - unitPrice: res.data.cost.total, - unitTime: res.data.print_time_seconds, - unitWeight: res.data.material_grams, - currency: 'CHF' - }; - } - - return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 }; - } - private mapMaterial(mat: string): string { const m = mat.toUpperCase(); if (m.includes('PLA')) return 'pla_basic'; @@ -333,13 +265,6 @@ export class QuoteEstimatorService { return 'pla_basic'; } - private mapQuality(qual: string): string { - const q = qual.toLowerCase(); - if (q.includes('draft')) return 'draft'; - if (q.includes('high')) return 'extra_fine'; - return 'standard'; - } - // Consultation Data Transfer private pendingConsultation = signal<{files: File[], message: string} | null>(null); diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts index 30eec60..651208a 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts @@ -1,10 +1,11 @@ -import { Component, signal, effect } from '@angular/core'; +import { Component, signal, effect, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; 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 { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; +import { QuoteRequestService } from '../../../../core/services/quote-request.service'; interface FilePreview { file: File; @@ -37,6 +38,8 @@ export class ContactFormComponent { { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } ]; + private quoteRequestService = inject(QuoteRequestService); + constructor( private fb: FormBuilder, private translate: TranslateService, @@ -156,13 +159,34 @@ export class ContactFormComponent { onSubmit() { if (this.form.valid) { - const formData = { - ...this.form.value, - files: this.files().map(f => f.file) - }; - console.log('Form Submit:', formData); + const formVal = this.form.value; + const isCompany = formVal.isCompany; + + const requestDto: any = { + requestType: formVal.requestType, + customerType: isCompany ? 'BUSINESS' : 'PRIVATE', + email: formVal.email, + phone: formVal.phone, + message: formVal.message + }; + + if (isCompany) { + requestDto.companyName = formVal.companyName; + requestDto.contactPerson = formVal.referencePerson; + } else { + requestDto.name = formVal.name; + } + + this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({ + next: () => { + this.sent.set(true); + }, + error: (err) => { + console.error('Submission failed', err); + alert('Error submitting request. Please try again.'); + } + }); - this.sent.set(true); } else { this.form.markAllAsTouched(); }