feat(back-end & front-end): add session file
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m14s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s

This commit is contained in:
2026-02-12 19:24:58 +01:00
parent 044fba8d5a
commit 44f9408b22
4 changed files with 229 additions and 1 deletions

View File

@@ -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: () => {

View File

@@ -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);

View File

@@ -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<Blob> {
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
};
}
}