diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss
index f3350cd..fd36719 100644
--- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss
@@ -2,8 +2,8 @@
margin-bottom: var(--space-6);
}
.upload-privacy-note {
- margin-top: var(--space-6);
- margin-bottom: 0;
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-1);
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: left;
@@ -35,48 +35,50 @@
/* Grid Layout for Files */
.items-grid {
display: grid;
- grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
- gap: var(--space-2); /* Tighten gap for mobile */
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media (min-width: 640px) {
+ grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
}
.file-card {
- padding: var(--space-2); /* Reduced from space-3 */
- background: var(--color-neutral-100);
+ padding: var(--space-3);
+ background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
- gap: 4px; /* Reduced gap */
- position: relative; /* For absolute positioning of remove btn */
- min-width: 0; /* Allow flex item to shrink below content size if needed */
+ gap: var(--space-2);
+ position: relative;
+ min-width: 0;
&:hover {
border-color: var(--color-neutral-300);
+ box-shadow: 0 4px 10px rgba(10, 20, 30, 0.07);
}
&.active {
border-color: var(--color-brand);
- background: rgba(250, 207, 10, 0.05);
+ background: rgba(250, 207, 10, 0.08);
box-shadow: 0 0 0 1px var(--color-brand);
}
}
.card-header {
overflow: hidden;
- padding-right: 25px; /* Adjusted */
- margin-bottom: 2px;
+ padding-right: 28px;
+ margin-bottom: 0;
}
.file-name {
- font-weight: 500;
- font-size: 0.8rem; /* Smaller font */
+ font-weight: 600;
+ font-size: 0.92rem;
color: var(--color-text);
display: block;
white-space: nowrap;
@@ -92,47 +94,46 @@
.card-controls {
display: flex;
- align-items: flex-end; /* Align bottom of input and color circle */
- gap: 16px; /* Space between Qty and Color */
+ align-items: flex-end;
+ gap: var(--space-4);
width: 100%;
}
.qty-group,
.color-group {
display: flex;
- flex-direction: column; /* Stack label and input */
+ flex-direction: column;
align-items: flex-start;
- gap: 0px;
+ gap: 2px;
label {
- font-size: 0.6rem;
+ font-size: 0.72rem;
color: var(--color-text-muted);
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.3px;
font-weight: 600;
- margin-bottom: 2px;
+ margin-bottom: 0;
}
}
.color-group {
- align-items: flex-start; /* Align label left */
- /* margin-right removed */
+ align-items: flex-start;
- /* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
}
.qty-input {
- width: 36px; /* Slightly smaller */
- padding: 1px 2px;
+ width: 54px;
+ padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
- font-size: 0.85rem;
+ font-size: 0.95rem;
+ font-weight: 600;
background: white;
- height: 24px; /* Explicit height to match color circle somewhat */
+ height: 34px;
&:focus {
outline: none;
border-color: var(--color-brand);
@@ -141,10 +142,10 @@
.btn-remove {
position: absolute;
- top: 4px;
- right: 4px;
- width: 18px;
- height: 18px;
+ top: 6px;
+ right: 6px;
+ width: 20px;
+ height: 20px;
border-radius: 4px;
border: none;
background: transparent;
@@ -155,7 +156,7 @@
align-items: center;
justify-content: center;
transition: all 0.2s;
- font-size: 0.8rem;
+ font-size: 0.9rem;
&:hover {
background: var(--color-danger-100);
@@ -170,7 +171,7 @@
.btn-add-more {
width: 100%;
- padding: var(--space-3);
+ padding: 0.75rem var(--space-3);
background: var(--color-neutral-800);
color: white;
border: none;
@@ -193,6 +194,50 @@
}
}
+.sync-settings {
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-neutral-50);
+ padding: var(--space-3);
+}
+
+.sync-settings-toggle {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ cursor: pointer;
+
+ input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ margin-top: 2px;
+ accent-color: var(--color-brand);
+ flex-shrink: 0;
+ }
+}
+
+.sync-settings-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sync-settings-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--color-text);
+ line-height: 1.2;
+}
+
+.sync-settings-subtitle {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ line-height: 1.35;
+}
+
.checkbox-row {
display: flex;
align-items: center;
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 d608ce6..1fdd614 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
@@ -22,6 +22,7 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import {
QuoteRequest,
+ QuoteRequestItem,
QuoteEstimatorService,
OptionsResponse,
SimpleOption,
@@ -34,33 +35,33 @@ interface FormItem {
file: File;
previewFile?: File;
quantity: number;
- material?: string;
- quality?: string;
- color: string;
- filamentVariantId?: number;
- supportEnabled?: boolean;
- infillDensity?: number;
- infillPattern?: string;
- layerHeight?: number;
- nozzleDiameter?: number;
- printSettings: ItemPrintSettings;
-}
-
-interface ItemPrintSettings {
material: string;
quality: string;
- nozzleDiameter: number;
- layerHeight: number;
+ color: string;
+ filamentVariantId?: number;
+ supportEnabled: boolean;
infillDensity: number;
infillPattern: string;
- supportEnabled: boolean;
+ layerHeight: number;
+ nozzleDiameter: number;
}
interface ItemSettingsDiffInfo {
differences: string[];
}
-type ItemPrintSettingsUpdate = Partial;
+type ItemPrintSettingsUpdate = Partial<
+ Pick<
+ FormItem,
+ | 'material'
+ | 'quality'
+ | 'nozzleDiameter'
+ | 'layerHeight'
+ | 'infillDensity'
+ | 'infillPattern'
+ | 'supportEnabled'
+ >
+>;
@Component({
selector: 'app-upload-form',
@@ -83,6 +84,7 @@ export class UploadFormComponent implements OnInit {
lockedSettings = input(false);
loading = input(false);
uploadProgress = input(0);
+
submitRequest = output();
itemQuantityChange = output<{
index: number;
@@ -109,37 +111,148 @@ export class UploadFormComponent implements OnInit {
items = signal([]);
selectedFile = signal(null);
+ sameSettingsForAll = signal(true);
- // Dynamic Options
materials = signal([]);
qualities = signal([]);
nozzleDiameters = signal([]);
infillPatterns = signal([]);
layerHeights = signal([]);
+ currentMaterialVariants = signal([]);
- // Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = [];
private allLayerHeights: SimpleOption[] = [];
private layerHeightsByNozzle: Record = {};
private isPatchingSettings = false;
- sameSettingsForAll = signal(true);
-
- // Computed variants for valid material
- currentMaterialVariants = signal([]);
-
- 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 : []);
- this.syncSelectedItemVariantSelection();
- } else {
- this.currentMaterialVariants.set([]);
- }
- }
acceptedFormats = '.stl,.3mf,.step,.stp';
+ constructor() {
+ this.form = this.fb.group({
+ itemsTouched: [false],
+ syncAllItems: [true],
+ material: ['', Validators.required],
+ quality: ['standard', Validators.required],
+ notes: [''],
+ infillDensity: [15, [Validators.min(0), Validators.max(100)]],
+ layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
+ nozzleDiameter: [0.4, Validators.required],
+ infillPattern: ['grid', Validators.required],
+ supportEnabled: [false],
+ });
+
+ this.form.get('material')?.valueChanges.subscribe((value) => {
+ this.updateVariants(String(value || ''));
+ });
+
+ this.form.get('quality')?.valueChanges.subscribe((quality) => {
+ if (this.isPatchingSettings || this.mode() !== 'easy') {
+ return;
+ }
+ this.applyEasyPresetFromQuality(String(quality || 'standard'));
+ });
+
+ this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => {
+ if (this.isPatchingSettings) {
+ return;
+ }
+ this.updateLayerHeightOptionsForNozzle(nozzle, true);
+ });
+
+ this.form.valueChanges.subscribe(() => {
+ if (this.isPatchingSettings) {
+ return;
+ }
+
+ if (this.sameSettingsForAll()) {
+ this.applyGlobalSettingsToAllItems();
+ } else {
+ this.syncSelectedItemSettingsFromForm();
+ }
+
+ this.emitPrintSettingsChange();
+ this.emitItemSettingsDiffChange();
+ });
+
+ effect(() => {
+ this.applySettingsLock(this.lockedSettings());
+ });
+
+ effect(() => {
+ if (this.mode() !== 'easy' || this.sameSettingsForAll()) {
+ return;
+ }
+
+ this.sameSettingsForAll.set(true);
+ this.form.get('syncAllItems')?.setValue(true, { emitEvent: false });
+ this.applyGlobalSettingsToAllItems();
+ this.emitPrintSettingsChange();
+ this.emitItemSettingsDiffChange();
+ });
+ }
+
+ ngOnInit() {
+ this.estimator.getOptions().subscribe({
+ next: (options: OptionsResponse) => {
+ this.fullMaterialOptions = options.materials || [];
+
+ 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.nozzleDiameters.set(
+ (options.nozzleDiameters || []).map((n) => ({ label: n.label, value: n.value })),
+ );
+
+ this.allLayerHeights = (options.layerHeights || []).map((l) => ({
+ label: l.label,
+ value: l.value,
+ }));
+
+ this.layerHeightsByNozzle = {};
+ (options.layerHeightsByNozzle || []).forEach((entry) => {
+ this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] = (
+ entry.layerHeights || []
+ ).map((layer) => ({
+ label: layer.label,
+ value: layer.value,
+ }));
+ });
+
+ this.setDefaults();
+ },
+ error: (err) => {
+ console.error('Failed to load options', err);
+ this.materials.set([
+ {
+ label: this.translate.instant('CALC.FALLBACK_MATERIAL'),
+ value: 'PLA',
+ },
+ ]);
+ this.qualities.set([
+ {
+ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'),
+ value: 'standard',
+ },
+ ]);
+ this.infillPatterns.set([{ label: 'Grid', value: 'grid' }]);
+ this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
+
+ this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }];
+ this.layerHeightsByNozzle = {
+ [this.toNozzleKey(0.4)]: this.allLayerHeights,
+ };
+
+ this.setDefaults();
+ },
+ });
+ }
+
isStlFile(file: File | null): boolean {
if (!file) return false;
const name = file.name.toLowerCase();
@@ -154,8 +267,7 @@ export class UploadFormComponent implements OnInit {
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;
+ return item ? item.previewFile || item.file : null;
}
getSelectedItemIndex(): number {
@@ -167,290 +279,107 @@ export class UploadFormComponent implements OnInit {
getSelectedItem(): FormItem | null {
const index = this.getSelectedItemIndex();
if (index < 0) return null;
- return this.items()[index] ?? null;
+ return this.items()[index] || null;
}
getVariantsForMaterial(materialCode: string | null | undefined): VariantOption[] {
- if (!materialCode) return [];
- const found = this.fullMaterialOptions.find((m) => m.code === materialCode);
- return found?.variants ?? [];
- }
+ const normalized = String(materialCode || '').trim().toUpperCase();
+ if (!normalized) return [];
- constructor() {
- this.form = this.fb.group({
- itemsTouched: [false], // Hack to track touched state for custom items list
- syncAllItems: [true],
- material: ['', Validators.required],
- quality: ['', Validators.required],
- items: [[]], // Track items in form for validation if needed
- notes: [''],
- // Advanced fields
- infillDensity: [15, [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 and propagate when "all files equal" is active.
- this.form.get('material')?.valueChanges.subscribe((materialCode) => {
- this.updateVariants();
- if (this.sameSettingsForAll() && !this.isPatchingSettings) {
- this.applyGlobalMaterialToAll(String(materialCode || 'PLA'));
- }
- });
-
- this.form.get('quality')?.valueChanges.subscribe((quality) => {
- if (this.mode() !== 'easy' || this.isPatchingSettings) return;
- this.applyAdvancedPresetFromQuality(quality);
- if (this.sameSettingsForAll()) {
- this.applyGlobalFieldToAll('quality', String(quality || 'standard'));
- }
- });
-
- this.form.get('nozzleDiameter')?.valueChanges.subscribe((value) => {
- if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
- this.applyGlobalFieldToAll(
- 'nozzleDiameter',
- Number.isFinite(Number(value)) ? Number(value) : 0.4,
- );
- });
- this.form.get('layerHeight')?.valueChanges.subscribe((value) => {
- if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
- this.applyGlobalFieldToAll(
- 'layerHeight',
- Number.isFinite(Number(value)) ? Number(value) : 0.2,
- );
- });
- this.form.get('infillDensity')?.valueChanges.subscribe((value) => {
- if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
- this.applyGlobalFieldToAll(
- 'infillDensity',
- Number.isFinite(Number(value)) ? Number(value) : 15,
- );
- });
- this.form.get('infillPattern')?.valueChanges.subscribe((value) => {
- if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
- this.applyGlobalFieldToAll('infillPattern', String(value || 'grid'));
- });
- this.form.get('supportEnabled')?.valueChanges.subscribe((value) => {
- if (!this.sameSettingsForAll() || this.isPatchingSettings) return;
- this.applyGlobalFieldToAll('supportEnabled', !!value);
- });
- this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => {
- if (this.isPatchingSettings) return;
- this.updateLayerHeightOptionsForNozzle(nozzle, true);
- });
- this.form.valueChanges.subscribe(() => {
- if (this.isPatchingSettings) return;
- this.syncSelectedItemSettingsFromForm();
- this.emitPrintSettingsChange();
- this.emitItemSettingsDiffChange();
- });
-
- effect(() => {
- this.applySettingsLock(this.lockedSettings());
- });
- }
-
- private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
- const normalized = (quality || 'standard').toLowerCase();
-
- const presets: Record<
- string,
- {
- nozzleDiameter: number;
- layerHeight: number;
- infillDensity: number;
- infillPattern: string;
- }
- > = {
- standard: {
- nozzleDiameter: 0.4,
- layerHeight: 0.2,
- infillDensity: 15,
- infillPattern: 'grid',
- },
- extra_fine: {
- nozzleDiameter: 0.4,
- layerHeight: 0.12,
- infillDensity: 20,
- infillPattern: 'grid',
- },
- high: {
- nozzleDiameter: 0.4,
- layerHeight: 0.12,
- infillDensity: 20,
- infillPattern: 'grid',
- }, // Legacy alias
- draft: {
- nozzleDiameter: 0.4,
- layerHeight: 0.24,
- infillDensity: 12,
- infillPattern: 'grid',
- },
- };
-
- const preset = presets[normalized] || presets['standard'];
- this.form.patchValue(preset, { emitEvent: false });
- this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true);
- }
-
- 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.allLayerHeights = options.layerHeights.map((l) => ({
- label: l.label,
- value: l.value,
- }));
- this.layerHeightsByNozzle = {};
- (options.layerHeightsByNozzle || []).forEach((entry) => {
- this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] =
- entry.layerHeights.map((layer) => ({
- label: layer.label,
- value: layer.value,
- }));
- });
- this.layerHeights.set(this.allLayerHeights);
- 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: this.translate.instant('CALC.FALLBACK_MATERIAL'),
- value: 'PLA',
- },
- ]);
- this.qualities.set([
- {
- label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'),
- value: 'standard',
- },
- ]);
- this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }];
- this.layerHeightsByNozzle = {
- [this.toNozzleKey(0.4)]: this.allLayerHeights,
- };
- this.layerHeights.set(this.allLayerHeights);
- 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) {
- const exactPla = this.materials().find(
- (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA',
- );
- const anyPla = this.materials().find(
- (m) =>
- typeof m.value === 'string' &&
- m.value.toUpperCase().startsWith('PLA'),
- );
- const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0];
- this.form.get('material')?.setValue(preferredMaterial.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
- }
-
- this.updateLayerHeightOptionsForNozzle(
- this.form.get('nozzleDiameter')?.value,
- true,
+ const found = this.fullMaterialOptions.find(
+ (m) => String(m.code || '').trim().toUpperCase() === normalized,
);
+ return found?.variants || [];
+ }
- if (
- this.infillPatterns().length > 0 &&
- !this.form.get('infillPattern')?.value
- ) {
- this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
+ getLayerHeightOptionsForNozzle(nozzleRaw: unknown): SimpleOption[] {
+ const key = this.toNozzleKey(nozzleRaw);
+ const perNozzle = this.layerHeightsByNozzle[key];
+ if (perNozzle && perNozzle.length > 0) {
+ return perNozzle;
}
-
- this.emitPrintSettingsChange();
+ return this.allLayerHeights.length > 0
+ ? this.allLayerHeights
+ : [{ label: '0.20 mm', value: 0.2 }];
}
onFilesDropped(newFiles: File[]) {
- const MAX_SIZE = 200 * 1024 * 1024; // 200MB
+ const MAX_SIZE = 200 * 1024 * 1024;
const validItems: FormItem[] = [];
let hasError = false;
+
const defaults = this.getCurrentGlobalItemDefaults();
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
- } else {
- const defaultSelection = this.getDefaultVariantSelection(defaults.material);
- validItems.push({
- file,
- previewFile: this.isStlFile(file) ? file : undefined,
- quantity: 1,
- material: defaults.material,
- quality: defaults.quality,
- color: defaultSelection.colorName,
- filamentVariantId: defaultSelection.filamentVariantId,
- supportEnabled: defaults.supportEnabled,
- infillDensity: defaults.infillDensity,
- infillPattern: defaults.infillPattern,
- layerHeight: defaults.layerHeight,
- nozzleDiameter: defaults.nozzleDiameter,
- printSettings: this.getCurrentItemPrintSettings(),
- });
+ continue;
}
+
+ const selection = this.getDefaultVariantSelection(defaults.material);
+ validItems.push({
+ file,
+ previewFile: this.isStlFile(file) ? file : undefined,
+ quantity: 1,
+ material: defaults.material,
+ quality: defaults.quality,
+ color: selection.colorName,
+ filamentVariantId: selection.filamentVariantId,
+ supportEnabled: defaults.supportEnabled,
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ layerHeight: defaults.layerHeight,
+ nozzleDiameter: defaults.nozzleDiameter,
+ });
}
if (hasError) {
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
}
- if (validItems.length > 0) {
- this.items.update((current) => [...current, ...validItems]);
- this.form.get('itemsTouched')?.setValue(true);
- // Auto select last added
- this.selectFile(validItems[validItems.length - 1].file);
- this.emitItemSettingsDiffChange();
+ if (validItems.length === 0) {
+ return;
}
+
+ this.items.update((current) => [...current, ...validItems]);
+ this.form.get('itemsTouched')?.setValue(true);
+
+ if (this.sameSettingsForAll()) {
+ this.applyGlobalSettingsToAllItems();
+ }
+
+ this.selectFile(validItems[validItems.length - 1].file);
+ this.emitItemSettingsDiffChange();
}
onAdditionalFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- this.onFilesDropped(Array.from(input.files));
- // Reset input so same files can be selected again if needed
- input.value = '';
+ if (!input.files || input.files.length === 0) {
+ return;
}
+
+ this.onFilesDropped(Array.from(input.files));
+ input.value = '';
+ }
+
+ updateItemQuantity(index: number, event: Event) {
+ const input = event.target as HTMLInputElement;
+ const parsed = parseInt(input.value, 10);
+ const quantity = Number.isFinite(parsed) ? parsed : 1;
+
+ const currentItem = this.items()[index];
+ if (!currentItem) {
+ return;
+ }
+
+ const normalizedQty = this.normalizeQuantity(quantity);
+ this.updateItemQuantityByIndex(index, quantity);
+
+ this.itemQuantityChange.emit({
+ index,
+ fileName: currentItem.file.name,
+ quantity: normalizedQty,
+ });
}
updateItemQuantityByIndex(index: number, quantity: number) {
@@ -459,75 +388,71 @@ export class UploadFormComponent implements OnInit {
this.items.update((current) => {
if (index >= current.length) return current;
- const applyToAll = this.sameSettingsForAll();
- return current.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- return { ...item, quantity: normalizedQty };
- });
+
+ if (this.sameSettingsForAll()) {
+ return current.map((item) => ({ ...item, quantity: normalizedQty }));
+ }
+
+ return current.map((item, idx) =>
+ idx === index ? { ...item, quantity: normalizedQty } : item,
+ );
});
}
updateItemQuantityByName(fileName: string, quantity: number) {
const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity);
- const applyToAll = this.sameSettingsForAll();
this.items.update((current) => {
let matched = false;
+
return current.map((item) => {
- if (applyToAll) {
+ if (this.sameSettingsForAll()) {
return { ...item, quantity: normalizedQty };
}
+
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true;
return { ...item, quantity: normalizedQty };
}
+
return item;
});
});
}
selectFile(file: File) {
- if (this.selectedFile() === file) {
- // toggle off? no, keep active
- } else {
+ if (this.selectedFile() !== file) {
this.selectedFile.set(file);
}
this.loadSelectedItemSettingsIntoForm();
}
- // Helper to get color of currently selected file
getSelectedFileColor(): string {
- const file = this.selectedFile();
- if (!file) return '#facf0a'; // Default
-
- const item = this.items().find((i) => i.file === file);
- if (item) {
- const vars = this.getVariantsForMaterial(item.material);
- if (vars && vars.length > 0) {
- const found = item.filamentVariantId
- ? vars.find((v) => v.id === item.filamentVariantId)
- : vars.find((v) => v.colorName === item.color);
- if (found) return found.hexColor;
- }
- return getColorHex(item.color);
+ const selected = this.selectedFile();
+ if (!selected) {
+ return '#facf0a';
}
- return '#facf0a';
- }
- updateItemQuantity(index: number, event: Event) {
- const input = event.target as HTMLInputElement;
- const parsed = parseInt(input.value, 10);
- const quantity = Number.isFinite(parsed) ? parsed : 1;
- const currentItem = this.items()[index];
- if (!currentItem) return;
- const normalizedQty = this.normalizeQuantity(quantity);
- this.updateItemQuantityByIndex(index, quantity);
- this.itemQuantityChange.emit({
- index,
- fileName: currentItem.file.name,
- quantity: normalizedQty,
- });
+ const item = this.items().find((i) => i.file === selected);
+ if (!item) {
+ return '#facf0a';
+ }
+
+ const variants = this.getVariantsForMaterial(item.material);
+ if (variants.length > 0) {
+ const byId =
+ item.filamentVariantId != null
+ ? variants.find((v) => v.id === item.filamentVariantId)
+ : null;
+ const byColor = variants.find((v) => v.colorName === item.color);
+ const selectedVariant = byId || byColor;
+ if (selectedVariant) {
+ return selectedVariant.hexColor;
+ }
+ }
+
+ return getColorHex(item.color);
}
updateItemColor(
@@ -537,464 +462,734 @@ export class UploadFormComponent implements OnInit {
const colorName =
typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId =
- typeof newSelection === 'string'
- ? undefined
- : newSelection.filamentVariantId;
- this.items.update((current) => {
- const updated = [...current];
- const applyToAll = this.sameSettingsForAll();
- return updated.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- return {
- ...item,
- color: colorName,
- filamentVariantId,
- };
- });
- });
- }
-
- updateItemMaterial(index: number, materialCode: string) {
- if (!Number.isInteger(index) || index < 0) return;
- const variants = this.getVariantsForMaterial(materialCode);
- const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
+ typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId;
this.items.update((current) => {
- if (index >= current.length) return current;
- const applyToAll = this.sameSettingsForAll();
- return current.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- const next = { ...item, material: materialCode };
- if (fallback) {
- next.color = fallback.colorName;
- next.filamentVariantId = fallback.id;
- } else {
- next.filamentVariantId = undefined;
- }
- return next;
- });
+ if (index < 0 || index >= current.length) {
+ return current;
+ }
+
+ return current.map((item, idx) =>
+ idx === index
+ ? {
+ ...item,
+ color: colorName,
+ filamentVariantId,
+ }
+ : item,
+ );
});
}
- updateSelectedItemNumberField(
- field:
- | 'nozzleDiameter'
- | 'layerHeight'
- | 'infillDensity'
- | 'quantity',
- value: number,
- ) {
- const index = this.getSelectedItemIndex();
- if (index < 0) return;
- const normalized =
- field === 'quantity'
- ? this.normalizeQuantity(value)
- : Number.isFinite(value)
- ? value
- : undefined;
-
- this.items.update((current) => {
- if (index >= current.length) return current;
- const applyToAll = this.sameSettingsForAll();
- return current.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- return {
- ...item,
- [field]: normalized,
- };
- });
- });
- }
-
- updateSelectedItemStringField(
- field: 'quality' | 'infillPattern',
- value: string,
- ) {
- const index = this.getSelectedItemIndex();
- if (index < 0) return;
- this.items.update((current) => {
- if (index >= current.length) return current;
- const applyToAll = this.sameSettingsForAll();
- return current.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- return {
- ...item,
- [field]: value,
- };
- });
- });
- }
-
- updateSelectedItemSupport(value: boolean) {
- const index = this.getSelectedItemIndex();
- if (index < 0) return;
- this.items.update((current) => {
- if (index >= current.length) return current;
- const applyToAll = this.sameSettingsForAll();
- return current.map((item, idx) => {
- if (!applyToAll && idx !== index) return item;
- return {
- ...item,
- supportEnabled: value,
- };
- });
- });
- }
-
- onSameSettingsToggle(enabled: boolean) {
- this.sameSettingsForAll.set(enabled);
- if (!enabled) {
- // Keep per-file values aligned with what the user sees in global controls
- // right before switching to single-file mode.
- this.syncAllItemsWithGlobalForm();
- return;
- }
-
- const selected = this.getSelectedItem() ?? this.items()[0];
- if (!selected) return;
-
- const normalizedQuality = this.normalizeQualityValue(
- selected.quality ?? this.form.get('quality')?.value,
- );
-
- this.isPatchingSettings = true;
- this.form.patchValue(
- {
- material: selected.material || this.form.get('material')?.value || 'PLA',
- quality: normalizedQuality,
- nozzleDiameter:
- selected.nozzleDiameter ?? this.form.get('nozzleDiameter')?.value ?? 0.4,
- layerHeight:
- selected.layerHeight ?? this.form.get('layerHeight')?.value ?? 0.2,
- infillDensity:
- selected.infillDensity ?? this.form.get('infillDensity')?.value ?? 15,
- infillPattern:
- selected.infillPattern || this.form.get('infillPattern')?.value || 'grid',
- supportEnabled:
- selected.supportEnabled ??
- this.form.get('supportEnabled')?.value ??
- false,
- },
- { emitEvent: false },
- );
- this.isPatchingSettings = false;
-
- const sharedPatch: Partial = {
- quantity: selected.quantity,
- material: selected.material,
- quality: normalizedQuality,
- color: selected.color,
- filamentVariantId: selected.filamentVariantId,
- supportEnabled: selected.supportEnabled,
- infillDensity: selected.infillDensity,
- infillPattern: selected.infillPattern,
- layerHeight: selected.layerHeight,
- nozzleDiameter: selected.nozzleDiameter,
- };
-
- this.items.update((current) =>
- current.map((item) => ({
- ...item,
- ...sharedPatch,
- })),
- );
- }
-
- private applyGlobalMaterialToAll(materialCode: string): void {
- const normalizedMaterial = materialCode || 'PLA';
- const variants = this.getVariantsForMaterial(normalizedMaterial);
- const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
- this.items.update((current) =>
- current.map((item) => ({
- ...item,
- material: normalizedMaterial,
- color: fallback ? fallback.colorName : item.color,
- filamentVariantId: fallback ? fallback.id : item.filamentVariantId,
- })),
- );
- }
-
- private applyGlobalFieldToAll(
- field:
- | 'quality'
- | 'nozzleDiameter'
- | 'layerHeight'
- | 'infillDensity'
- | 'infillPattern'
- | 'supportEnabled',
- value: string | number | boolean,
- ): void {
- this.items.update((current) =>
- current.map((item) => ({
- ...item,
- [field]: value,
- })),
- );
- }
-
- patchItemSettingsByIndex(index: number, patch: Partial) {
- if (!Number.isInteger(index) || index < 0) return;
- const normalizedPatch: Partial = { ...patch };
- if (normalizedPatch.quality !== undefined && normalizedPatch.quality !== null) {
- normalizedPatch.quality = this.normalizeQualityValue(normalizedPatch.quality);
- }
- this.items.update((current) => {
- if (index >= current.length) return current;
- const updated = [...current];
- updated[index] = { ...updated[index], ...normalizedPatch };
- return updated;
- });
- this.emitItemSettingsDiffChange();
- }
-
- setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) {
- if (!Number.isInteger(index) || index < 0) return;
-
- let selectedItemUpdated = false;
- this.items.update((current) => {
- if (index >= current.length) return current;
- const updated = [...current];
- const target = updated[index];
- if (!target) return current;
-
- const merged: ItemPrintSettings = {
- ...target.printSettings,
- ...update,
- };
-
- updated[index] = {
- ...target,
- printSettings: merged,
- };
- selectedItemUpdated = target.file === this.selectedFile();
- return updated;
- });
-
- if (selectedItemUpdated) {
- this.loadSelectedItemSettingsIntoForm();
- this.emitPrintSettingsChange();
- }
- this.emitItemSettingsDiffChange();
- }
-
removeItem(index: number) {
let nextSelected: File | null = null;
+
this.items.update((current) => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
- if (this.selectedFile() === removed.file) {
- nextSelected = updated.length > 0 ? updated[Math.max(0, index - 1)].file : null;
+ if (!removed) {
+ return current;
}
+
+ if (this.selectedFile() === removed.file) {
+ nextSelected =
+ updated.length > 0 ? updated[Math.max(0, index - 1)].file : null;
+ }
+
return updated;
});
+
if (nextSelected) {
this.selectFile(nextSelected);
} else if (this.items().length === 0) {
this.selectedFile.set(null);
}
+
this.emitItemSettingsDiffChange();
}
- setFiles(files: File[]) {
- const validItems: FormItem[] = [];
- const defaults = this.getCurrentGlobalItemDefaults();
- const defaultSelection = this.getDefaultVariantSelection(defaults.material);
- for (const file of files) {
- validItems.push({
- file,
- previewFile: this.isStlFile(file) ? file : undefined,
- quantity: 1,
- material: defaults.material,
- quality: defaults.quality,
- color: defaultSelection.colorName,
- filamentVariantId: defaultSelection.filamentVariantId,
- supportEnabled: defaults.supportEnabled,
- infillDensity: defaults.infillDensity,
- infillPattern: defaults.infillPattern,
- layerHeight: defaults.layerHeight,
- nozzleDiameter: defaults.nozzleDiameter,
- });
+ onSameSettingsToggle(enabled: boolean) {
+ this.sameSettingsForAll.set(enabled);
+ this.form.get('syncAllItems')?.setValue(enabled, { emitEvent: false });
+
+ if (enabled) {
+ this.applyGlobalSettingsToAllItems();
+ } else {
+ this.loadSelectedItemSettingsIntoForm();
}
- if (validItems.length > 0) {
- this.items.set(validItems);
- this.form.get('itemsTouched')?.setValue(true);
- // Auto select last added
- this.selectFile(validItems[validItems.length - 1].file);
- this.emitItemSettingsDiffChange();
- }
- }
-
- 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 getCurrentGlobalItemDefaults(): Omit & {
- material: string;
- quality: string;
- } {
- return {
- material: this.form.get('material')?.value || 'PLA',
- quality: this.normalizeQualityValue(this.form.get('quality')?.value),
- supportEnabled: !!this.form.get('supportEnabled')?.value,
- infillDensity: Number(this.form.get('infillDensity')?.value ?? 15),
- infillPattern: this.form.get('infillPattern')?.value || 'grid',
- layerHeight: Number(this.form.get('layerHeight')?.value ?? 0.2),
- nozzleDiameter: Number(this.form.get('nozzleDiameter')?.value ?? 0.4),
- };
- }
-
- private getDefaultVariantSelection(materialCode?: string): {
- colorName: string;
- filamentVariantId?: number;
- } {
- const vars = materialCode
- ? this.getVariantsForMaterial(materialCode)
- : this.currentMaterialVariants();
- if (vars && vars.length > 0) {
- const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
- return {
- colorName: preferred.colorName,
- filamentVariantId: preferred.id,
- };
- }
- return { colorName: 'Black' };
- }
-
- getVariantsForItem(item: FormItem): VariantOption[] {
- return this.getVariantsForMaterialCode(item.printSettings.material);
- }
-
- private getVariantsForMaterialCode(materialCodeRaw: string): VariantOption[] {
- const materialCode = String(materialCodeRaw || '').toUpperCase();
- if (!materialCode) {
- return [];
- }
- const material = this.fullMaterialOptions.find(
- (option) => String(option.code || '').toUpperCase() === materialCode,
- );
- return material?.variants || [];
- }
-
- private syncSelectedItemVariantSelection(): void {
- const vars = this.currentMaterialVariants();
- if (!vars || vars.length === 0) {
- return;
- }
-
- const selected = this.selectedFile();
- if (!selected) {
- return;
- }
-
- const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
- this.items.update((current) =>
- current.map((item) => {
- if (item.file !== selected) {
- return item;
- }
- const byId =
- item.filamentVariantId != null
- ? vars.find((v) => v.id === item.filamentVariantId)
- : null;
- const byColor = vars.find((v) => v.colorName === item.color);
- const selectedVariant = byId || byColor || fallback;
- return {
- ...item,
- color: selectedVariant.colorName,
- filamentVariantId: selectedVariant.id,
- };
- }),
- );
+ this.emitPrintSettingsChange();
+ this.emitItemSettingsDiffChange();
}
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.24) patch.quality = 'draft';
- else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine';
- else patch.quality = 'standard';
-
- patch.layerHeight = settings.layerHeightMm;
+ const layer = Number(settings.layerHeightMm);
+ if (Number.isFinite(layer)) {
+ patch.layerHeight = layer;
+ patch.quality =
+ layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard';
}
- if (settings.nozzleDiameterMm)
- patch.nozzleDiameter = settings.nozzleDiameterMm;
- if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
+ const nozzle = Number(settings.nozzleDiameterMm);
+ if (Number.isFinite(nozzle)) patch.nozzleDiameter = nozzle;
+
+ const infill = Number(settings.infillPercent);
+ if (Number.isFinite(infill)) patch.infillDensity = infill;
+
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined)
- patch.supportEnabled = settings.supportsEnabled;
+ patch.supportEnabled = Boolean(settings.supportsEnabled);
if (settings.notes) patch.notes = settings.notes;
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
+
+ this.updateVariants(String(this.form.get('material')?.value || ''));
this.updateLayerHeightOptionsForNozzle(
this.form.get('nozzleDiameter')?.value,
true,
);
+
+ if (this.sameSettingsForAll()) {
+ this.applyGlobalSettingsToAllItems();
+ } else {
+ this.syncSelectedItemSettingsFromForm();
+ }
+
this.emitPrintSettingsChange();
+ this.emitItemSettingsDiffChange();
+ }
+
+ setFiles(files: File[]) {
+ const defaults = this.getCurrentGlobalItemDefaults();
+ const selection = this.getDefaultVariantSelection(defaults.material);
+
+ const validItems: FormItem[] = files.map((file) => ({
+ file,
+ previewFile: this.isStlFile(file) ? file : undefined,
+ quantity: 1,
+ material: defaults.material,
+ quality: defaults.quality,
+ color: selection.colorName,
+ filamentVariantId: selection.filamentVariantId,
+ supportEnabled: defaults.supportEnabled,
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ layerHeight: defaults.layerHeight,
+ nozzleDiameter: defaults.nozzleDiameter,
+ }));
+
+ this.items.set(validItems);
+
+ if (validItems.length > 0) {
+ this.form.get('itemsTouched')?.setValue(true);
+ this.selectFile(validItems[validItems.length - 1].file);
+ } else {
+ this.selectedFile.set(null);
+ }
+
+ this.emitItemSettingsDiffChange();
+ }
+
+ setPreviewFileByIndex(index: number, previewFile: File) {
+ if (!Number.isInteger(index) || index < 0) return;
+
+ this.items.update((current) => {
+ if (index >= current.length) return current;
+ return current.map((item, idx) =>
+ idx === index ? { ...item, previewFile } : item,
+ );
+ });
+ }
+
+ setItemPrintSettingsByIndex(index: number, update: ItemPrintSettingsUpdate) {
+ if (!Number.isInteger(index) || index < 0) return;
+
+ this.items.update((current) => {
+ if (index >= current.length) return current;
+
+ return current.map((item, idx) => {
+ if (idx !== index) {
+ return item;
+ }
+
+ let next: FormItem = {
+ ...item,
+ ...update,
+ };
+
+ if (update.quality !== undefined) {
+ next.quality = this.normalizeQualityValue(update.quality);
+ }
+
+ if (update.material !== undefined) {
+ const variants = this.getVariantsForMaterial(update.material);
+ const byId =
+ next.filamentVariantId != null
+ ? variants.find((v) => v.id === next.filamentVariantId)
+ : null;
+ const byColor = variants.find((v) => v.colorName === next.color);
+ const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
+ const variant = byId || byColor || fallback;
+ if (variant) {
+ next = {
+ ...next,
+ color: variant.colorName,
+ filamentVariantId: variant.id,
+ };
+ }
+ }
+
+ return next;
+ });
+ });
+
+ this.refreshSameSettingsFlag();
+
+ if (!this.sameSettingsForAll() && this.getSelectedItemIndex() === index) {
+ this.loadSelectedItemSettingsIntoForm();
+ this.emitPrintSettingsChange();
+ }
+
+ this.emitItemSettingsDiffChange();
+ }
+
+ getCurrentRequestDraft(): QuoteRequest {
+ const defaults = this.getCurrentGlobalItemDefaults();
+
+ const items: QuoteRequestItem[] = this.items().map((item) =>
+ this.toRequestItem(item, defaults),
+ );
+
+ return {
+ items,
+ material: defaults.material,
+ quality: defaults.quality,
+ notes: this.form.get('notes')?.value || '',
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ supportEnabled: defaults.supportEnabled,
+ layerHeight: defaults.layerHeight,
+ nozzleDiameter: defaults.nozzleDiameter,
+ mode: this.mode(),
+ };
}
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) {
- const items = this.items();
- const firstItemMaterial = items[0]?.material;
- console.log(
- 'UploadFormComponent: Emitting submitRequest',
- this.form.value,
- );
- this.submitRequest.emit({
- ...this.form.getRawValue(),
- 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,
- );
- }
- });
+ if (!this.form.valid || this.items().length === 0) {
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
+ return;
}
+
+ this.submitRequest.emit(this.getCurrentRequestDraft());
+ }
+
+ private setDefaults() {
+ if (this.materials().length > 0 && !this.form.get('material')?.value) {
+ const exactPla = this.materials().find(
+ (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA',
+ );
+ const fallback = exactPla || this.materials()[0];
+ this.form.get('material')?.setValue(fallback.value, { emitEvent: false });
+ }
+
+ if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
+ const standard = this.qualities().find((q) => q.value === 'standard');
+ this.form
+ .get('quality')
+ ?.setValue(standard ? standard.value : this.qualities()[0].value, {
+ emitEvent: false,
+ });
+ }
+
+ if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
+ this.form.get('nozzleDiameter')?.setValue(0.4, { emitEvent: false });
+ }
+
+ if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
+ this.form
+ .get('infillPattern')
+ ?.setValue(this.infillPatterns()[0].value, { emitEvent: false });
+ }
+
+ this.updateVariants(String(this.form.get('material')?.value || ''));
+ this.updateLayerHeightOptionsForNozzle(
+ this.form.get('nozzleDiameter')?.value,
+ true,
+ );
+
+ if (this.mode() === 'easy') {
+ this.applyEasyPresetFromQuality(String(this.form.get('quality')?.value || 'standard'));
+ }
+
+ this.emitPrintSettingsChange();
+ }
+
+ private applyEasyPresetFromQuality(qualityRaw: string) {
+ const preset = this.easyModePresetForQuality(qualityRaw);
+
+ this.isPatchingSettings = true;
+ this.form.patchValue(
+ {
+ quality: preset.quality,
+ nozzleDiameter: preset.nozzleDiameter,
+ layerHeight: preset.layerHeight,
+ infillDensity: preset.infillDensity,
+ infillPattern: preset.infillPattern,
+ },
+ { emitEvent: false },
+ );
+ this.isPatchingSettings = false;
+
+ this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true);
+ }
+
+ private easyModePresetForQuality(qualityRaw: string): {
+ quality: string;
+ nozzleDiameter: number;
+ layerHeight: number;
+ infillDensity: number;
+ infillPattern: string;
+ } {
+ const quality = this.normalizeQualityValue(qualityRaw);
+
+ if (quality === 'draft') {
+ return {
+ quality: 'draft',
+ nozzleDiameter: 0.4,
+ layerHeight: 0.28,
+ infillDensity: 15,
+ infillPattern: 'grid',
+ };
+ }
+
+ if (quality === 'extra_fine') {
+ return {
+ quality: 'extra_fine',
+ nozzleDiameter: 0.4,
+ layerHeight: 0.12,
+ infillDensity: 20,
+ infillPattern: 'gyroid',
+ };
+ }
+
+ return {
+ quality: 'standard',
+ nozzleDiameter: 0.4,
+ layerHeight: 0.2,
+ infillDensity: 15,
+ infillPattern: 'grid',
+ };
+ }
+
+ private getCurrentGlobalItemDefaults(): {
+ material: string;
+ quality: string;
+ nozzleDiameter: number;
+ layerHeight: number;
+ infillDensity: number;
+ infillPattern: string;
+ supportEnabled: boolean;
+ } {
+ const material = String(this.form.get('material')?.value || 'PLA');
+ const quality = this.normalizeQualityValue(this.form.get('quality')?.value);
+
+ if (this.mode() === 'easy') {
+ const preset = this.easyModePresetForQuality(quality);
+ return {
+ material,
+ quality: preset.quality,
+ nozzleDiameter: preset.nozzleDiameter,
+ layerHeight: preset.layerHeight,
+ infillDensity: preset.infillDensity,
+ infillPattern: preset.infillPattern,
+ supportEnabled: Boolean(this.form.get('supportEnabled')?.value),
+ };
+ }
+
+ return {
+ material,
+ quality,
+ nozzleDiameter: this.normalizeNumber(this.form.get('nozzleDiameter')?.value, 0.4),
+ layerHeight: this.normalizeNumber(this.form.get('layerHeight')?.value, 0.2),
+ infillDensity: this.normalizeNumber(this.form.get('infillDensity')?.value, 20),
+ infillPattern: String(this.form.get('infillPattern')?.value || 'grid'),
+ supportEnabled: Boolean(this.form.get('supportEnabled')?.value),
+ };
+ }
+
+ private toRequestItem(
+ item: FormItem,
+ defaults: ReturnType,
+ ): QuoteRequestItem {
+ const quality = this.normalizeQualityValue(item.quality || defaults.quality);
+
+ if (this.mode() === 'easy') {
+ const preset = this.easyModePresetForQuality(quality);
+ return {
+ file: item.file,
+ quantity: this.normalizeQuantity(item.quantity),
+ material: item.material || defaults.material,
+ quality: preset.quality,
+ color: item.color,
+ filamentVariantId: item.filamentVariantId,
+ supportEnabled: item.supportEnabled ?? defaults.supportEnabled,
+ infillDensity: preset.infillDensity,
+ infillPattern: preset.infillPattern,
+ layerHeight: preset.layerHeight,
+ nozzleDiameter: preset.nozzleDiameter,
+ };
+ }
+
+ return {
+ file: item.file,
+ quantity: this.normalizeQuantity(item.quantity),
+ material: item.material || defaults.material,
+ quality,
+ color: item.color,
+ filamentVariantId: item.filamentVariantId,
+ supportEnabled: item.supportEnabled,
+ infillDensity: this.normalizeNumber(item.infillDensity, defaults.infillDensity),
+ infillPattern: item.infillPattern || defaults.infillPattern,
+ layerHeight: this.normalizeNumber(item.layerHeight, defaults.layerHeight),
+ nozzleDiameter: this.normalizeNumber(item.nozzleDiameter, defaults.nozzleDiameter),
+ };
+ }
+
+ private applyGlobalSettingsToAllItems() {
+ const defaults = this.getCurrentGlobalItemDefaults();
+ const variants = this.getVariantsForMaterial(defaults.material);
+ const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
+
+ this.items.update((current) =>
+ current.map((item) => {
+ const byId =
+ item.filamentVariantId != null
+ ? variants.find((v) => v.id === item.filamentVariantId)
+ : null;
+ const byColor = variants.find((v) => v.colorName === item.color);
+ const selectedVariant = byId || byColor || fallback;
+
+ return {
+ ...item,
+ material: defaults.material,
+ quality: defaults.quality,
+ nozzleDiameter: defaults.nozzleDiameter,
+ layerHeight: defaults.layerHeight,
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ supportEnabled: defaults.supportEnabled,
+ color: selectedVariant ? selectedVariant.colorName : item.color,
+ filamentVariantId: selectedVariant
+ ? selectedVariant.id
+ : item.filamentVariantId,
+ };
+ }),
+ );
+ }
+
+ private syncSelectedItemSettingsFromForm() {
+ if (this.sameSettingsForAll()) {
+ return;
+ }
+
+ const index = this.getSelectedItemIndex();
+ if (index < 0) {
+ return;
+ }
+
+ const defaults = this.getCurrentGlobalItemDefaults();
+
+ this.items.update((current) => {
+ if (index >= current.length) return current;
+
+ return current.map((item, idx) => {
+ if (idx !== index) {
+ return item;
+ }
+
+ const variants = this.getVariantsForMaterial(defaults.material);
+ const byId =
+ item.filamentVariantId != null
+ ? variants.find((v) => v.id === item.filamentVariantId)
+ : null;
+ const byColor = variants.find((v) => v.colorName === item.color);
+ const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
+ const selectedVariant = byId || byColor || fallback;
+
+ return {
+ ...item,
+ material: defaults.material,
+ quality: defaults.quality,
+ nozzleDiameter: defaults.nozzleDiameter,
+ layerHeight: defaults.layerHeight,
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ supportEnabled: defaults.supportEnabled,
+ color: selectedVariant ? selectedVariant.colorName : item.color,
+ filamentVariantId: selectedVariant
+ ? selectedVariant.id
+ : item.filamentVariantId,
+ };
+ });
+ });
+ }
+
+ private loadSelectedItemSettingsIntoForm() {
+ if (this.sameSettingsForAll()) {
+ return;
+ }
+
+ const selected = this.getSelectedItem();
+ if (!selected) {
+ return;
+ }
+
+ this.isPatchingSettings = true;
+ this.form.patchValue(
+ {
+ material: selected.material,
+ quality: this.normalizeQualityValue(selected.quality),
+ nozzleDiameter: selected.nozzleDiameter,
+ layerHeight: selected.layerHeight,
+ infillDensity: selected.infillDensity,
+ infillPattern: selected.infillPattern,
+ supportEnabled: selected.supportEnabled,
+ },
+ { emitEvent: false },
+ );
+ this.isPatchingSettings = false;
+
+ this.updateVariants(selected.material);
+ this.updateLayerHeightOptionsForNozzle(selected.nozzleDiameter, true);
+ }
+
+ private updateVariants(materialCode: string) {
+ const variants = this.getVariantsForMaterial(materialCode);
+ this.currentMaterialVariants.set(variants);
+
+ if (this.sameSettingsForAll() || !this.selectedFile()) {
+ return;
+ }
+
+ if (variants.length === 0) {
+ return;
+ }
+
+ const selectedIndex = this.getSelectedItemIndex();
+ if (selectedIndex < 0) {
+ return;
+ }
+
+ this.items.update((current) => {
+ if (selectedIndex >= current.length) {
+ return current;
+ }
+
+ const selectedItem = current[selectedIndex];
+ const byId =
+ selectedItem.filamentVariantId != null
+ ? variants.find((v) => v.id === selectedItem.filamentVariantId)
+ : null;
+ const byColor = variants.find((v) => v.colorName === selectedItem.color);
+ const fallback = variants.find((v) => !v.isOutOfStock) || variants[0];
+ const selectedVariant = byId || byColor || fallback;
+
+ if (!selectedVariant) {
+ return current;
+ }
+
+ return current.map((item, idx) =>
+ idx === selectedIndex
+ ? {
+ ...item,
+ color: selectedVariant.colorName,
+ filamentVariantId: selectedVariant.id,
+ }
+ : item,
+ );
+ });
+ }
+
+ private updateLayerHeightOptionsForNozzle(
+ nozzleRaw: unknown,
+ clampCurrentLayer: boolean,
+ ) {
+ const options = this.getLayerHeightOptionsForNozzle(nozzleRaw);
+ this.layerHeights.set(options);
+
+ if (!clampCurrentLayer || options.length === 0) {
+ return;
+ }
+
+ const currentLayer = this.normalizeNumber(this.form.get('layerHeight')?.value, options[0].value as number);
+ const allowed = options.some(
+ (option) =>
+ Math.abs(this.normalizeNumber(option.value, currentLayer) - currentLayer) <
+ 0.0001,
+ );
+
+ if (allowed) {
+ return;
+ }
+
+ this.isPatchingSettings = true;
+ this.form.patchValue(
+ {
+ layerHeight: Number(options[0].value),
+ },
+ { emitEvent: false },
+ );
+ this.isPatchingSettings = false;
+ }
+
+ private emitPrintSettingsChange() {
+ const defaults = this.getCurrentGlobalItemDefaults();
+ this.printSettingsChange.emit({
+ mode: this.mode(),
+ material: defaults.material,
+ quality: defaults.quality,
+ nozzleDiameter: defaults.nozzleDiameter,
+ layerHeight: defaults.layerHeight,
+ infillDensity: defaults.infillDensity,
+ infillPattern: defaults.infillPattern,
+ supportEnabled: defaults.supportEnabled,
+ });
+ }
+
+ private emitItemSettingsDiffChange() {
+ if (this.sameSettingsForAll()) {
+ this.itemSettingsDiffChange.emit({});
+ return;
+ }
+
+ const baseline = this.getCurrentGlobalItemDefaults();
+ const diffByFileName: Record = {};
+
+ this.items().forEach((item) => {
+ const differences: string[] = [];
+
+ if (this.normalizeText(item.material) !== this.normalizeText(baseline.material)) {
+ differences.push(item.material.toUpperCase());
+ }
+
+ if (this.mode() === 'easy') {
+ if (
+ this.normalizeText(item.quality) !== this.normalizeText(baseline.quality)
+ ) {
+ differences.push(`quality:${item.quality}`);
+ }
+ } else {
+ if (
+ Math.abs(
+ this.normalizeNumber(item.nozzleDiameter, baseline.nozzleDiameter) -
+ baseline.nozzleDiameter,
+ ) > 0.0001
+ ) {
+ differences.push(`nozzle:${item.nozzleDiameter}`);
+ }
+
+ if (
+ Math.abs(
+ this.normalizeNumber(item.layerHeight, baseline.layerHeight) -
+ baseline.layerHeight,
+ ) > 0.0001
+ ) {
+ differences.push(`layer:${item.layerHeight}`);
+ }
+
+ if (
+ Math.abs(
+ this.normalizeNumber(item.infillDensity, baseline.infillDensity) -
+ baseline.infillDensity,
+ ) > 0.0001
+ ) {
+ differences.push(`infill:${item.infillDensity}%`);
+ }
+
+ if (
+ this.normalizeText(item.infillPattern) !==
+ this.normalizeText(baseline.infillPattern)
+ ) {
+ differences.push(`pattern:${item.infillPattern}`);
+ }
+
+ if (Boolean(item.supportEnabled) !== Boolean(baseline.supportEnabled)) {
+ differences.push(
+ `support:${Boolean(item.supportEnabled) ? 'on' : 'off'}`,
+ );
+ }
+ }
+
+ if (differences.length > 0) {
+ diffByFileName[item.file.name] = { differences };
+ }
+ });
+
+ this.itemSettingsDiffChange.emit(diffByFileName);
+ }
+
+ private getDefaultVariantSelection(materialCode: string): {
+ colorName: string;
+ filamentVariantId?: number;
+ } {
+ const variants = this.getVariantsForMaterial(materialCode);
+ if (variants.length === 0) {
+ return { colorName: 'Black' };
+ }
+
+ const preferred = variants.find((v) => !v.isOutOfStock) || variants[0];
+ return {
+ colorName: preferred.colorName,
+ filamentVariantId: preferred.id,
+ };
+ }
+
+ private refreshSameSettingsFlag() {
+ const current = this.items();
+ if (current.length <= 1) {
+ return;
+ }
+
+ const first = current[0];
+ const allEqual = current.every((item) =>
+ this.sameItemSettings(first, item),
+ );
+
+ if (!allEqual) {
+ this.sameSettingsForAll.set(false);
+ this.form.get('syncAllItems')?.setValue(false, { emitEvent: false });
+ }
+ }
+
+ private sameItemSettings(a: FormItem, b: FormItem): boolean {
+ return (
+ this.normalizeText(a.material) === this.normalizeText(b.material) &&
+ this.normalizeText(a.quality) === this.normalizeText(b.quality) &&
+ Math.abs(this.normalizeNumber(a.nozzleDiameter, 0.4) - this.normalizeNumber(b.nozzleDiameter, 0.4)) <
+ 0.0001 &&
+ Math.abs(this.normalizeNumber(a.layerHeight, 0.2) - this.normalizeNumber(b.layerHeight, 0.2)) <
+ 0.0001 &&
+ Math.abs(this.normalizeNumber(a.infillDensity, 20) - this.normalizeNumber(b.infillDensity, 20)) <
+ 0.0001 &&
+ this.normalizeText(a.infillPattern) === this.normalizeText(b.infillPattern) &&
+ Boolean(a.supportEnabled) === Boolean(b.supportEnabled)
+ );
+ }
+
+ private normalizeQualityValue(value: any): string {
+ const normalized = String(value || 'standard').trim().toLowerCase();
+ if (normalized === 'high' || normalized === 'high_definition') {
+ return 'extra_fine';
+ }
+ return normalized || 'standard';
}
private normalizeQuantity(quantity: number): number {
@@ -1004,10 +1199,29 @@ export class UploadFormComponent implements OnInit {
return Math.floor(quantity);
}
+ private normalizeNumber(value: any, fallback: number): number {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? numeric : fallback;
+ }
+
+ private normalizeText(value: any): string {
+ return String(value || '')
+ .trim()
+ .toLowerCase();
+ }
+
private normalizeFileName(fileName: string): string {
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
+ private toNozzleKey(value: unknown): string {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return '0.40';
+ }
+ return numeric.toFixed(2);
+ }
+
private applySettingsLock(locked: boolean): void {
const controlsToLock = [
'syncAllItems',
@@ -1023,6 +1237,7 @@ export class UploadFormComponent implements OnInit {
controlsToLock.forEach((name) => {
const control = this.form.get(name);
if (!control) return;
+
if (locked) {
control.disable({ emitEvent: false });
} else {
From aa6322e9283a04b49727cc22bb48931361b3714f Mon Sep 17 00:00:00 2001
From: printcalc-ci
Date: Thu, 5 Mar 2026 16:28:39 +0000
Subject: [PATCH 08/10] style: apply prettier formatting
---
.../pages/admin-dashboard.component.html | 4 +-
.../admin/pages/admin-sessions.component.html | 4 +-
.../calculator/calculator-page.component.ts | 25 ++--
.../upload-form/upload-form.component.html | 7 +-
.../upload-form/upload-form.component.ts | 123 +++++++++++++-----
.../services/quote-estimator.service.ts | 54 +++++---
.../features/checkout/checkout.component.html | 5 +-
7 files changed, 160 insertions(+), 62 deletions(-)
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html
index b3f156b..521def9 100644
--- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html
+++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html
@@ -284,8 +284,8 @@
{{ item.originalFilename }}
{{ item.materialCode || "-" }} | {{ item.nozzleDiameterMm ?? "-" }} mm
- | {{ item.layerHeightMm ?? "-" }} mm | {{ item.infillPercent ?? "-" }}%
- | {{ item.infillPattern || "-" }} |
+ | {{ item.layerHeightMm ?? "-" }} mm |
+ {{ item.infillPercent ?? "-" }}% | {{ item.infillPattern || "-" }} |
{{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html
index 48ee86d..aea368c 100644
--- a/frontend/src/app/features/admin/pages/admin-sessions.component.html
+++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html
@@ -149,7 +149,9 @@
{{ item.layerHeightMm ?? "-" }} mm |
{{ item.infillPercent ?? "-" }}% |
{{ item.infillPattern || "-" }} |
- {{ item.supportsEnabled ? "Supporti ON" : "Supporti OFF" }}
+ {{
+ item.supportsEnabled ? "Supporti ON" : "Supporti OFF"
+ }}
{{ item.status }} |
{{ item.unitPriceChf | currency: "CHF" }} |
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts
index 8efa766..6737d44 100644
--- a/frontend/src/app/features/calculator/calculator-page.component.ts
+++ b/frontend/src/app/features/calculator/calculator-page.component.ts
@@ -72,7 +72,10 @@ export class CalculatorPageComponent implements OnInit {
Record
>({});
private baselinePrintSettings: TrackedPrintSettings | null = null;
- private baselineItemSettingsByFileName = new Map();
+ private baselineItemSettingsByFileName = new Map<
+ string,
+ TrackedPrintSettings
+ >();
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
@@ -528,7 +531,9 @@ export class CalculatorPageComponent implements OnInit {
});
}
- private toTrackedSettingsFromRequest(req: QuoteRequest): TrackedPrintSettings {
+ private toTrackedSettingsFromRequest(
+ req: QuoteRequest,
+ ): TrackedPrintSettings {
return {
mode: req.mode,
material: this.normalizeString(req.material || 'PLA'),
@@ -590,17 +595,17 @@ export class CalculatorPageComponent implements OnInit {
item: any,
fallback: TrackedPrintSettings,
): TrackedPrintSettings {
- const layer = this.normalizeNumber(item?.layerHeightMm, fallback.layerHeight, 3);
+ const layer = this.normalizeNumber(
+ item?.layerHeightMm,
+ fallback.layerHeight,
+ 3,
+ );
return {
mode: this.mode(),
material: this.normalizeString(item?.materialCode || fallback.material),
quality: this.normalizeString(
item?.quality ||
- (layer >= 0.24
- ? 'draft'
- : layer <= 0.12
- ? 'extra_fine'
- : 'standard'),
+ (layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'),
),
nozzleDiameter: this.normalizeNumber(
item?.nozzleDiameterMm,
@@ -616,9 +621,7 @@ export class CalculatorPageComponent implements OnInit {
infillPattern: this.normalizeString(
item?.infillPattern || fallback.infillPattern,
),
- supportEnabled: Boolean(
- item?.supportsEnabled ?? fallback.supportEnabled,
- ),
+ supportEnabled: Boolean(item?.supportsEnabled ?? fallback.supportEnabled),
};
}
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html
index 843fb2e..25ab039 100644
--- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html
@@ -226,7 +226,12 @@