feat(back-end): db connections and other stuff
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 3s

This commit is contained in:
2026-02-10 19:07:37 +01:00
parent 3b4ef37e58
commit e5183590c5
42 changed files with 2015 additions and 182 deletions

15
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,15 @@
# Stage 1: Build
FROM node:20 as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Use development configuration to pick up environment.ts (localhost)
RUN npm run build -- --configuration=development
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -48,6 +48,7 @@
<label>COLORE</label>
<app-color-selector
[selectedColor]="item.color"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
@@ -80,20 +81,20 @@
<app-select
formControlName="material"
[label]="'CALC.MATERIAL' | translate"
[options]="materials"
[options]="materials()"
></app-select>
@if (mode() === 'easy') {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters"
[options]="nozzleDiameters()"
></app-select>
}
</div>
@@ -105,13 +106,13 @@
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights"
[options]="layerHeights()"
></app-select>
</div>
@@ -147,7 +148,7 @@
<app-button
type="submit"
[disabled]="form.invalid || items().length === 0 || loading()"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
</app-button>

View File

@@ -1,4 +1,4 @@
import { Component, input, output, signal, effect } from '@angular/core';
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -8,7 +8,7 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest } from '../../services/quote-estimator.service';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
@@ -24,69 +24,110 @@ interface FormItem {
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
})
export class UploadFormComponent {
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>();
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
form: FormGroup;
items = signal<FormItem[]>([]);
selectedFile = signal<File | null>(null);
materials = [
{ label: 'PLA (Standard)', value: 'PLA' },
{ label: 'PETG (Resistente)', value: 'PETG' },
{ label: 'TPU (Flessibile)', value: 'TPU' }
];
qualities = [
{ label: 'Bozza (Fast)', value: 'Draft' },
{ label: 'Standard', value: 'Standard' },
{ label: 'Alta definizione', value: 'High' }
];
nozzleDiameters = [
{ label: '0.2 mm (+2 CHF)', value: 0.2 },
{ label: '0.4 mm (Standard)', value: 0.4 },
{ label: '0.6 mm (+2 CHF)', value: 0.6 },
{ label: '0.8 mm (+2 CHF)', value: 0.8 }
];
// Dynamic Options
materials = signal<SimpleOption[]>([]);
qualities = signal<SimpleOption[]>([]);
nozzleDiameters = signal<SimpleOption[]>([]);
infillPatterns = signal<SimpleOption[]>([]);
layerHeights = signal<SimpleOption[]>([]);
infillPatterns = [
{ label: 'Grid', value: 'grid' },
{ label: 'Gyroid', value: 'gyroid' },
{ label: 'Cubic', value: 'cubic' },
{ label: 'Triangles', value: 'triangles' }
];
layerHeights = [
{ label: '0.08 mm', value: 0.08 },
{ label: '0.12 mm (High Quality - Slow)', value: 0.12 },
{ label: '0.16 mm', value: 0.16 },
{ label: '0.20 mm (Standard)', value: 0.20 },
{ label: '0.24 mm', value: 0.24 },
{ label: '0.28 mm', value: 0.28 }
];
// Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = [];
// Computed variants for valid material
currentMaterialVariants = signal<VariantOption[]>([]);
private updateVariants() {
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
} else {
this.currentMaterialVariants.set([]);
}
}
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
constructor(private fb: FormBuilder) {
constructor() {
this.form = this.fb.group({
itemsTouched: [false], // Hack to track touched state for custom items list
material: ['PLA', Validators.required],
quality: ['Standard', Validators.required],
// Print Speed removed
material: ['', Validators.required],
quality: ['', Validators.required],
items: [[]], // Track items in form for validation if needed
notes: [''],
// Advanced fields
// Color removed from global form
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
});
// Listen to material changes to update variants
this.form.get('material')?.valueChanges.subscribe(() => {
this.updateVariants();
});
}
ngOnInit() {
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
this.setDefaults();
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([{ label: 'PLA (Fallback)', value: 'PLA' }]);
this.qualities.set([{ label: 'Standard', value: 'standard' }]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
}
});
}
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
}
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
}
onFilesDropped(newFiles: File[]) {
@@ -187,13 +228,25 @@ export class UploadFormComponent {
}
onSubmit() {
console.log('UploadFormComponent: onSubmit triggered');
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
this.submitRequest.emit({
items: this.items(), // Pass the items array including colors
...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode()
});
} else {
console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
}
});
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
@@ -49,14 +49,70 @@ interface BackendResponse {
error?: string;
}
// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
}
export interface VariantOption {
name: string;
colorName: string;
hexColor: string;
isOutOfStock: boolean;
}
export interface QualityOption {
id: string;
label: string;
}
export interface InfillOption {
id: string;
label: string;
}
export interface NumericOption {
value: number;
label: string;
}
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
}
// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
tap({
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
})
);
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
if (request.items.length === 0) return of();
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
console.warn('QuoteEstimatorService: No items to calculate');
return of();
}
return new Observable(observer => {
const totalItems = request.items.length;
@@ -67,7 +123,24 @@ export class QuoteEstimatorService {
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
formData.append('machine', 'bambu_a1');
// 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));
@@ -104,9 +177,6 @@ export class QuoteEstimatorService {
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
// Even if error, we count as complete
// But we need to handle completion logic carefully.
// For simplicity, let's treat it as complete but check later.
}
const event = wrapper.event;
@@ -159,8 +229,6 @@ export class QuoteEstimatorService {
});
if (items.length === 0) {
// If at least one failed? Or all?
// For now if NO items succeeded, error.
observer.error('All calculations failed.');
return;
}
@@ -196,7 +264,6 @@ export class QuoteEstimatorService {
},
error: (err) => {
console.error('Error in request subscription', err);
// Should be caught by inner pipe, but safety net
completedRequests++;
if (completedRequests === totalItems) {
observer.error('Requests failed');

View File

@@ -12,7 +12,7 @@
@if (isOpen()) {
<div class="color-popup">
@for (category of categories; track category.name) {
@for (category of categories(); track category.name) {
<div class="category">
<div class="category-name">{{ category.name }}</div>
<div class="colors-grid">

View File

@@ -1,7 +1,8 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
@Component({
selector: 'app-color-selector',
@@ -12,11 +13,28 @@ import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../.
})
export class ColorSelectorComponent {
selectedColor = input<string>('Black');
variants = input<VariantOption[]>([]);
colorSelected = output<string>();
isOpen = signal(false);
categories: ColorCategory[] = PRODUCT_COLORS;
categories = computed(() => {
const vars = this.variants();
if (vars && vars.length > 0) {
// Flatten variants into a single category for now
// We could try to group by extracting words, but "Colors" is fine.
return [{
name: 'Available Colors',
colors: vars.map(v => ({
label: v.colorName, // Display "Red"
value: v.colorName, // Send "Red" to backend
hex: v.hexColor,
outOfStock: v.isOutOfStock
}))
}] as ColorCategory[];
}
return PRODUCT_COLORS;
});
toggleOpen() {
this.isOpen.update(v => !v);
@@ -31,6 +49,13 @@ export class ColorSelectorComponent {
// Helper to find hex for the current selected value
getCurrentHex(): string {
// Check in dynamic variants first
const vars = this.variants();
if (vars && vars.length > 0) {
const found = vars.find(v => v.colorName === this.selectedColor());
if (found) return found.hexColor;
}
return getColorHex(this.selectedColor());
}

View File

@@ -1,5 +1,5 @@
export const environment = {
production: false,
apiUrl: 'https://dev.3d-fab.ch',
apiUrl: 'http://localhost:8000',
basicAuth: 'fab:0presura' // Format: 'username:password'
};