dev #15

Merged
JoeKung merged 10 commits from dev into main 2026-03-04 11:02:43 +01:00
5 changed files with 102 additions and 19 deletions
Showing only changes of commit 685cd704e7 - Show all commits

View File

@@ -426,6 +426,7 @@ public class QuoteSessionController {
dto.put("colorCode", item.getColorCode()); dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("status", item.getStatus()); dto.put("status", item.getStatus());
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
BigDecimal unitPrice = item.getUnitPriceChf(); BigDecimal unitPrice = item.getUnitPriceChf();
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
@@ -486,7 +487,8 @@ public class QuoteSessionController {
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent( public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
@PathVariable UUID sessionId, @PathVariable UUID sessionId,
@PathVariable UUID lineItemId @PathVariable UUID lineItemId,
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
) throws IOException { ) throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
@@ -495,20 +497,32 @@ public class QuoteSessionController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (item.getStoredPath() == null) { String targetStoredPath = item.getStoredPath();
if (preview) {
String convertedPath = extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
}
}
if (targetStoredPath == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId); Path path = resolveStoredQuotePath(targetStoredPath, sessionId);
if (path == null || !Files.exists(path)) { if (path == null || !Files.exists(path)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
org.springframework.core.io.Resource resource = new UrlResource(path.toUri()); org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
String downloadName = item.getOriginalFilename();
if (preview) {
downloadName = path.getFileName().toString();
}
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
.body(resource); .body(resource);
} }
@@ -549,4 +563,17 @@ public class QuoteSessionController {
return null; return null;
} }
} }
private String extractConvertedStoredPath(QuoteLineItem item) {
Map<String, Object> breakdown = item.getPricingBreakdown();
if (breakdown == null) {
return null;
}
Object converted = breakdown.get("convertedStoredPath");
if (converted == null) {
return null;
}
String path = String.valueOf(converted).trim();
return path.isEmpty() ? null : path;
}
} }

View File

@@ -8,8 +8,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { forkJoin } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
@@ -128,15 +128,18 @@ export class CalculatorPageComponent implements OnInit {
// Download all files // Download all files
const downloads = items.map((item) => const downloads = items.map((item) =>
this.estimator.getLineItemContent(session.id, item.id).pipe( forkJoin({
map((blob: Blob) => { 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 { return {
blob, originalBlob,
previewBlob,
fileName: item.originalFilename, fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally. hasConvertedPreview: !!item.convertedStoredPath,
// 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.
}; };
}), }),
), ),
@@ -146,13 +149,25 @@ export class CalculatorPageComponent implements OnInit {
next: (results: any[]) => { next: (results: any[]) => {
const files = results.map( const files = results.map(
(res) => (res) =>
new File([res.blob], res.fileName, { new File([res.originalBlob], res.fileName, {
type: 'application/octet-stream', type: 'application/octet-stream',
}), }),
); );
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.setFiles(files); 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); this.uploadForm.patchSettings(session);
// Also restore colors? // Also restore colors?
@@ -231,6 +246,17 @@ export class CalculatorPageComponent implements OnInit {
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any 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" 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);
},
});
} }
} }
}, },

View File

@@ -2,13 +2,13 @@
<div class="section"> <div class="section">
@if (selectedFile()) { @if (selectedFile()) {
<div class="viewer-wrapper"> <div class="viewer-wrapper">
@if (!isStepFile(selectedFile())) { @if (!canPreviewSelectedFile()) {
<div class="step-warning"> <div class="step-warning">
<p>{{ "CALC.STEP_WARNING" | translate }}</p> <p>{{ "CALC.STEP_WARNING" | translate }}</p>
</div> </div>
} @else { } @else {
<app-stl-viewer <app-stl-viewer
[file]="selectedFile()" [file]="getSelectedPreviewFile()"
[color]="getSelectedFileColor()" [color]="getSelectedFileColor()"
> >
</app-stl-viewer> </app-stl-viewer>

View File

@@ -32,6 +32,7 @@ import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem { interface FormItem {
file: File; file: File;
previewFile?: File;
quantity: number; quantity: number;
color: string; color: string;
filamentVariantId?: number; filamentVariantId?: number;
@@ -96,12 +97,24 @@ export class UploadFormComponent implements OnInit {
acceptedFormats = '.stl,.3mf,.step,.stp'; acceptedFormats = '.stl,.3mf,.step,.stp';
isStepFile(file: File | null): boolean { isStlFile(file: File | null): boolean {
if (!file) return false; if (!file) return false;
const name = file.name.toLowerCase(); const name = file.name.toLowerCase();
return name.endsWith('.stl'); return name.endsWith('.stl');
} }
canPreviewSelectedFile(): boolean {
return this.isStlFile(this.getSelectedPreviewFile());
}
getSelectedPreviewFile(): File | null {
const selected = this.selectedFile();
if (!selected) return null;
const item = this.items().find((i) => i.file === selected);
if (!item) return null;
return item.previewFile ?? item.file;
}
constructor() { constructor() {
this.form = this.fb.group({ this.form = this.fb.group({
itemsTouched: [false], // Hack to track touched state for custom items list itemsTouched: [false], // Hack to track touched state for custom items list
@@ -262,6 +275,7 @@ export class UploadFormComponent implements OnInit {
const defaultSelection = this.getDefaultVariantSelection(); const defaultSelection = this.getDefaultVariantSelection();
validItems.push({ validItems.push({
file, file,
previewFile: this.isStlFile(file) ? file : undefined,
quantity: 1, quantity: 1,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId, filamentVariantId: defaultSelection.filamentVariantId,
@@ -390,6 +404,7 @@ export class UploadFormComponent implements OnInit {
for (const file of files) { for (const file of files) {
validItems.push({ validItems.push({
file, file,
previewFile: this.isStlFile(file) ? file : undefined,
quantity: 1, quantity: 1,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId, filamentVariantId: defaultSelection.filamentVariantId,
@@ -404,6 +419,16 @@ export class UploadFormComponent implements OnInit {
} }
} }
setPreviewFileByIndex(index: number, previewFile: File) {
if (!Number.isInteger(index) || index < 0) return;
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], previewFile };
return updated;
});
}
private getDefaultVariantSelection(): { private getDefaultVariantSelection(): {
colorName: string; colorName: string;
filamentVariantId?: number; filamentVariantId?: number;

View File

@@ -416,10 +416,15 @@ export class QuoteEstimatorService {
} }
// Session File Retrieval // Session File Retrieval
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> { getLineItemContent(
sessionId: string,
lineItemId: string,
preview = false,
): Observable<Blob> {
const headers: any = {}; const headers: any = {};
const previewQuery = preview ? '?preview=true' : '';
return this.http.get( return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content${previewQuery}`,
{ {
headers, headers,
responseType: 'blob', responseType: 'blob',