feat(web + backend): advanced and simple quote.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<div class="container fade-in">
|
||||
<header class="section-header">
|
||||
<a routerLink="/" class="back-link">
|
||||
<span class="material-icons">arrow_back</span> Back
|
||||
</a>
|
||||
<h1>Advanced Quote</h1>
|
||||
<p>Configure detailed print parameters for your project.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid-2 quote-layout">
|
||||
<!-- Left: Inputs -->
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<!-- Upload Area -->
|
||||
<div class="upload-area small"
|
||||
[class.drag-over]="isDragOver"
|
||||
[class.has-file]="selectedFile"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)">
|
||||
|
||||
<input #fileInput type="file" hidden (change)="onFileSelected($event)" accept=".stl">
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" *ngIf="!selectedFile" (click)="fileInput.click()">
|
||||
<span class="material-icons">cloud_upload</span>
|
||||
<p>Click or Drop STL here</p>
|
||||
</div>
|
||||
|
||||
<!-- Selected State -->
|
||||
<div *ngIf="selectedFile">
|
||||
<div class="viewer-wrapper">
|
||||
<app-stl-viewer [file]="selectedFile"></app-stl-viewer>
|
||||
</div>
|
||||
<div class="file-action-bar border-top">
|
||||
<div class="file-info">
|
||||
<span class="material-icons">description</span>
|
||||
<span class="file-name">{{ selectedFile.name }}</span>
|
||||
</div>
|
||||
<button class="btn-icon danger" (click)="removeFile($event)" title="Remove">
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Form -->
|
||||
<div class="params-form">
|
||||
<h3>Print Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Layer Height (mm)</label>
|
||||
<select [(ngModel)]="params.layerHeight">
|
||||
<option value="0.12">0.12 (High Quality)</option>
|
||||
<option value="0.16">0.16 (Quality)</option>
|
||||
<option value="0.20">0.20 (Standard)</option>
|
||||
<option value="0.24">0.24 (Draft)</option>
|
||||
<option value="0.28">0.28 (Extra Draft)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Infill Density (%)</label>
|
||||
<div class="range-wrapper">
|
||||
<input type="range" [(ngModel)]="params.infill" min="0" max="100" step="5">
|
||||
<span>{{ params.infill }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Wall Loops</label>
|
||||
<input type="number" [(ngModel)]="params.walls" min="1" max="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Top/Bottom Shells</label>
|
||||
<input type="number" [(ngModel)]="params.topBottom" min="1" max="10">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Material</label>
|
||||
<select [(ngModel)]="params.material">
|
||||
<option value="PLA">PLA</option>
|
||||
<option value="PETG">PETG</option>
|
||||
<option value="ABS">ABS</option>
|
||||
<option value="TPU">TPU</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-block"
|
||||
[disabled]="!selectedFile || isCalculating"
|
||||
(click)="calculate()">
|
||||
<span *ngIf="!isCalculating">Calculate Price</span>
|
||||
<span *ngIf="isCalculating">Calculating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div class="results-area" *ngIf="quoteResult">
|
||||
<div class="card result-card">
|
||||
<h2>Estimated Cost</h2>
|
||||
<div class="price-big">
|
||||
{{ quoteResult.cost.total | currency:'EUR' }}
|
||||
</div>
|
||||
|
||||
<div class="specs-list">
|
||||
<div class="spec-item">
|
||||
<span>Print Time</span>
|
||||
<strong>{{ quoteResult.print_time_formatted }}</strong>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span>Material Weight</span>
|
||||
<strong>{{ quoteResult.material_grams | number:'1.0-0' }}g</strong>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span>Printer</span>
|
||||
<strong>{{ quoteResult.printer }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Param Summary (Future Proofing) -->
|
||||
<div class="specs-list secondary">
|
||||
<div class="spec-header">Request Specs</div>
|
||||
<div class="spec-item compact">
|
||||
<span>Infill</span>
|
||||
<span>{{ params.infill }}%</span>
|
||||
</div>
|
||||
<div class="spec-item compact">
|
||||
<span>Layer Height</span>
|
||||
<span>{{ params.layerHeight }}mm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-box">
|
||||
<p>Note: Advanced parameters are saved for review but estimation currently uses standard profile benchmarks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="results-area empty" *ngIf="!quoteResult">
|
||||
<div class="card placeholder-card">
|
||||
<span class="material-icons">science</span>
|
||||
<h3>Advanced Quote</h3>
|
||||
<p>Configure settings and calculate.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,268 @@
|
||||
.section-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
|
||||
.material-icons {
|
||||
font-size: 1.1rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--surface-color);
|
||||
transition: all 0.2s;
|
||||
|
||||
&.drag-over {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
&:not(.has-file) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
.material-icons {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p { margin: 0; color: var(--text-muted);}
|
||||
}
|
||||
}
|
||||
|
||||
.viewer-wrapper {
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.file-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-color);
|
||||
|
||||
&.border-top { border-top: 1px solid var(--border-color); }
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-main);
|
||||
|
||||
.material-icons { color: var(--primary-color); font-size: 1.2rem; }
|
||||
.file-name { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
|
||||
.material-icons {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
.material-icons { color: #ef4444; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.params-form {
|
||||
padding: 1.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-main);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.range-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
span {
|
||||
width: 3rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Results - Reused mostly but tweaked */
|
||||
.result-card {
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, var(--surface-color) 0%, rgba(30, 41, 59, 0.8) 100%);
|
||||
|
||||
h2 {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.price-big {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.specs-list {
|
||||
text-align: left;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
|
||||
.spec-header {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
&.compact {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
span:first-child { color: var(--text-muted); }
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.note-box {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--primary-color); // Blueish info for advanced note
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
|
||||
.material-icons {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PrintService } from '../../print.service';
|
||||
import { StlViewerComponent } from '../../common/stl-viewer/stl-viewer.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-advanced-quote',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule, StlViewerComponent],
|
||||
templateUrl: './advanced-quote.component.html',
|
||||
styleUrls: ['./advanced-quote.component.scss']
|
||||
})
|
||||
export class AdvancedQuoteComponent {
|
||||
printService = inject(PrintService);
|
||||
|
||||
selectedFile: File | null = null;
|
||||
isDragOver = false;
|
||||
isCalculating = false;
|
||||
quoteResult: any = null;
|
||||
|
||||
// Parameters
|
||||
params = {
|
||||
layerHeight: '0.20',
|
||||
infill: 15,
|
||||
walls: 2,
|
||||
topBottom: 3,
|
||||
material: 'PLA'
|
||||
};
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isDragOver = true;
|
||||
}
|
||||
|
||||
onDragLeave(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isDragOver = false;
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isDragOver = false;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
if (files[0].name.toLowerCase().endsWith('.stl')) {
|
||||
this.selectedFile = files[0];
|
||||
this.quoteResult = null;
|
||||
} else {
|
||||
alert('Please upload an STL file.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFileSelected(event: any) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.selectedFile = file;
|
||||
this.quoteResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(event: Event) {
|
||||
event.stopPropagation();
|
||||
this.selectedFile = null;
|
||||
this.quoteResult = null;
|
||||
}
|
||||
|
||||
calculate() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
this.isCalculating = true;
|
||||
|
||||
// Use PrintService
|
||||
this.printService.calculateQuote(this.selectedFile, this.params)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.quoteResult = res;
|
||||
this.isCalculating = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
alert('Calculation failed: ' + (err.error?.detail || err.message));
|
||||
this.isCalculating = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user