feat(front-end): multiple file upload
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, input, output, signal, effect } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -9,6 +9,11 @@ import { AppButtonComponent } from '../../../../shared/components/app-button/app
|
|||||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||||
|
|
||||||
|
interface FormItem {
|
||||||
|
file: File;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload-form',
|
selector: 'app-upload-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -20,28 +25,53 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
@if (selectedFile()) {
|
@if (selectedFile()) {
|
||||||
<div class="viewer-wrapper">
|
<div class="viewer-wrapper">
|
||||||
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
|
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
|
||||||
<button type="button" class="btn-clear" (click)="clearFiles()">
|
<button type="button" class="btn-clear-viewer" (click)="selectedFile.set(null)">
|
||||||
X
|
Close Viewer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list">
|
}
|
||||||
@for (f of files(); track f.name) {
|
|
||||||
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
|
<!-- Dropzone always available if we want to add more, or hide if list not empty? -->
|
||||||
{{ f.name }}
|
<!-- User wants to "add more", so dropzone should remain or be available -->
|
||||||
</div>
|
<app-dropzone
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<app-dropzone
|
|
||||||
[label]="'CALC.UPLOAD_LABEL' | translate"
|
[label]="'CALC.UPLOAD_LABEL' | translate"
|
||||||
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
||||||
[accept]="acceptedFormats"
|
[accept]="acceptedFormats"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
(filesDropped)="onFilesDropped($event)">
|
(filesDropped)="onFilesDropped($event)">
|
||||||
</app-dropzone>
|
</app-dropzone>
|
||||||
|
|
||||||
|
<!-- New File List with Details -->
|
||||||
|
@if (items().length > 0) {
|
||||||
|
<div class="items-list">
|
||||||
|
@for (item of items(); track item.file.name; let i = $index) {
|
||||||
|
<div class="file-row" [class.active]="item.file === selectedFile()">
|
||||||
|
<div class="file-info" (click)="selectFile(item.file)">
|
||||||
|
<span class="file-name">{{ item.file.name }}</span>
|
||||||
|
<span class="file-size">{{ (item.file.size / 1024 / 1024) | number:'1.1-2' }} MB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-actions">
|
||||||
|
<div class="qty-control">
|
||||||
|
<label>Qtà</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[value]="item.quantity"
|
||||||
|
(change)="updateItemQuantity(i, $event)"
|
||||||
|
class="qty-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn-remove" (click)="removeItem(i)" title="Remove file">
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (form.get('files')?.invalid && form.get('files')?.touched) {
|
@if (items().length === 0 && form.get('itemsTouched')?.value) {
|
||||||
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -59,12 +89,8 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
[options]="qualities"
|
[options]="qualities"
|
||||||
></app-select>
|
></app-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-input
|
<!-- Global quantity removed, now per item -->
|
||||||
formControlName="quantity"
|
|
||||||
type="number"
|
|
||||||
[label]="'CALC.QUANTITY' | translate"
|
|
||||||
></app-input>
|
|
||||||
|
|
||||||
@if (mode() === 'advanced') {
|
@if (mode() === 'advanced') {
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -113,7 +139,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
|
|
||||||
<app-button
|
<app-button
|
||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="form.invalid || loading()"
|
[disabled]="form.invalid || items().length === 0 || loading()"
|
||||||
[fullWidth]="true">
|
[fullWidth]="true">
|
||||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||||
</app-button>
|
</app-button>
|
||||||
@@ -127,43 +153,95 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
||||||
|
|
||||||
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
||||||
.btn-clear {
|
.btn-clear-viewer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
width: 32px;
|
padding: 4px 8px;
|
||||||
height: 32px;
|
border-radius: 4px;
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
&:hover { background: rgba(0,0,0,0.7); }
|
&:hover { background: rgba(0,0,0,0.7); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.items-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
margin-top: var(--space-4);
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
}
|
}
|
||||||
.file-item {
|
|
||||||
padding: 0.5rem 1rem;
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
background: var(--color-neutral-100);
|
background: var(--color-neutral-100);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.85rem;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
&.active {
|
||||||
&:hover { background: var(--color-neutral-200); }
|
border-color: var(--color-brand);
|
||||||
&.active {
|
background: rgba(250, 207, 10, 0.05);
|
||||||
border-color: var(--color-brand);
|
|
||||||
background: rgba(250, 207, 10, 0.1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); }
|
||||||
|
.file-size { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 50px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent; // var(--color-neutral-200);
|
||||||
|
color: var(--color-text-muted); // var(--color-danger-500);
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger-100);
|
||||||
|
color: var(--color-danger-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-row {
|
.checkbox-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -185,9 +263,6 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
/* Progress Bar */
|
/* Progress Bar */
|
||||||
.progress-container {
|
.progress-container {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
/* padding: var(--space-2); */
|
|
||||||
/* background: var(--color-neutral-100); */
|
|
||||||
/* border-radius: var(--radius-md); */
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -206,7 +281,6 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
|||||||
width: 0%;
|
width: 0%;
|
||||||
transition: width 0.2s ease-out;
|
transition: width 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class UploadFormComponent {
|
export class UploadFormComponent {
|
||||||
@@ -217,7 +291,7 @@ export class UploadFormComponent {
|
|||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
|
|
||||||
files = signal<File[]>([]);
|
items = signal<FormItem[]>([]);
|
||||||
selectedFile = signal<File | null>(null);
|
selectedFile = signal<File | null>(null);
|
||||||
|
|
||||||
materials = [
|
materials = [
|
||||||
@@ -252,10 +326,9 @@ export class UploadFormComponent {
|
|||||||
|
|
||||||
constructor(private fb: FormBuilder) {
|
constructor(private fb: FormBuilder) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
files: [[], Validators.required],
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
material: ['PLA', Validators.required],
|
material: ['PLA', Validators.required],
|
||||||
quality: ['Standard', Validators.required],
|
quality: ['Standard', Validators.required],
|
||||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
|
||||||
notes: [''],
|
notes: [''],
|
||||||
// Advanced fields
|
// Advanced fields
|
||||||
color: ['Black'],
|
color: ['Black'],
|
||||||
@@ -267,14 +340,14 @@ export class UploadFormComponent {
|
|||||||
|
|
||||||
onFilesDropped(newFiles: File[]) {
|
onFilesDropped(newFiles: File[]) {
|
||||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||||
const validFiles: File[] = [];
|
const validItems: FormItem[] = [];
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (file.size > MAX_SIZE) {
|
if (file.size > MAX_SIZE) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
validFiles.push(file);
|
validItems.push({ file, quantity: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,32 +355,55 @@ export class UploadFormComponent {
|
|||||||
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
if (validItems.length > 0) {
|
||||||
this.files.update(current => [...current, ...validFiles]);
|
this.items.update(current => [...current, ...validItems]);
|
||||||
this.form.patchValue({ files: this.files() });
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
this.form.get('files')?.markAsTouched();
|
// Auto select last added
|
||||||
this.selectedFile.set(validFiles[validFiles.length - 1]);
|
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectFile(file: File) {
|
selectFile(file: File) {
|
||||||
this.selectedFile.set(file);
|
if (this.selectedFile() === file) {
|
||||||
|
// toggle off? no, keep active
|
||||||
|
} else {
|
||||||
|
this.selectedFile.set(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFiles() {
|
updateItemQuantity(index: number, event: Event) {
|
||||||
this.files.set([]);
|
const input = event.target as HTMLInputElement;
|
||||||
this.selectedFile.set(null);
|
let val = parseInt(input.value, 10);
|
||||||
this.form.patchValue({ files: [] });
|
if (isNaN(val) || val < 1) val = 1;
|
||||||
|
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: val };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(index: number) {
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
const removed = updated.splice(index, 1)[0];
|
||||||
|
if (this.selectedFile() === removed.file) {
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
if (this.form.valid) {
|
if (this.form.valid && this.items().length > 0) {
|
||||||
this.submitRequest.emit({
|
this.submitRequest.emit({
|
||||||
|
items: this.items(), // Pass the items array
|
||||||
...this.form.value,
|
...this.form.value,
|
||||||
mode: this.mode()
|
mode: this.mode()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user